@atomist/sdm-pack-aspect
Version:
an Atomist SDM Extension Pack for visualizing drift across an organization
433 lines (392 loc) • 16.4 kB
text/typescript
/*
* Copyright © 2019 Atomist, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
HttpClientFactory,
logger,
} from "@atomist/automation-client";
import { ExpressCustomizer } from "@atomist/automation-client/lib/configuration";
import {
ExtensionPackMetadata,
metadata,
} from "@atomist/sdm";
import {
ConcreteIdeal,
FP,
Ideal,
isConcreteIdeal,
} from "@atomist/sdm-pack-fingerprint";
import { Aspect } from "@atomist/sdm-pack-fingerprint/lib/machine/Aspect";
import * as bodyParser from "body-parser";
import {
Express,
RequestHandler,
} from "express";
import * as _ from "lodash";
import * as path from "path";
import { CSSProperties } from "react";
import serveStatic = require("serve-static");
import {
ProjectAspectForDisplay,
ProjectFingerprintForDisplay,
RepoExplorer,
} from "../../../views/repository";
import {
PossibleIdealForDisplay,
SunburstPage,
} from "../../../views/sunburstPage";
import { renderStaticReactNode } from "../../../views/topLevelPage";
import { ProjectAnalysisResultStore } from "../../analysis/offline/persist/ProjectAnalysisResultStore";
import { AnalysisTracking } from "../../analysis/tracking/analysisTracker";
import { exposeAnalysisTrackingPage } from "../../analysis/tracking/analysisTrackingRoutes";
import {
AspectRegistry,
} from "../../aspect/AspectRegistry";
import {
defaultedToDisplayableFingerprint,
defaultedToDisplayableFingerprintName,
} from "../../aspect/DefaultAspectRegistry";
import { CustomReporters } from "../../customize/customReporters";
import { PlantedTree } from "../../tree/sunburst";
import { visit } from "../../tree/treeUtils";
import {
describeSelectedTagsToAnimals,
TagTree,
} from "../api";
import { exposeOverviewPage } from "./overviewPage";
import { exposeRepositoryListPage } from "./repositoryListPage";
import { WebAppConfig } from "./webAppConfig";
/**
* Add the org page route to Atomist SDM Express server.
* @return {ExpressCustomizer}
*/
export function addWebAppRoutes(
aspectRegistry: AspectRegistry,
store: ProjectAnalysisResultStore,
analysisTracking: AnalysisTracking,
httpClientFactory: HttpClientFactory,
instanceMetadata: ExtensionPackMetadata): {
customizer: ExpressCustomizer,
routesToSuggestOnStartup: Array<{ title: string, route: string }>,
} {
const topLevelRoute = "/overview";
return {
routesToSuggestOnStartup: [{ title: "Atomist Visualizations", route: topLevelRoute }],
customizer: (express: Express, ...handlers: RequestHandler[]) => {
express.use(bodyParser.json()); // to support JSON-encoded bodies
express.use(bodyParser.urlencoded({ // to support URL-encoded bodies
extended: true,
}));
express.use(serveStatic(path.join(__dirname, "..", "..", "..", "public"), { index: false }));
express.use(serveStatic(path.join(__dirname, "..", "..", "..", "dist"), { index: false }));
/* redirect / to the org page. This way we can go right here
* for now, and later make a higher-level page if we want.
*/
express.get("/", ...handlers, (req, res) => {
res.redirect(topLevelRoute);
});
const conf: WebAppConfig = { express, handlers, aspectRegistry, store, instanceMetadata, httpClientFactory, analysisTracking };
exposeDriftPage(conf);
exposeOverviewPage(conf, topLevelRoute);
exposeRepositoryListPage(conf);
exposeRepositoryPage(conf);
exposeExplorePage(conf);
exposeFingerprintReportPage(conf);
exposeCustomReportPage(conf);
exposeAnalysisTrackingPage(conf);
},
};
}
function exposeRepositoryPage(conf: WebAppConfig): void {
conf.express.get("/repository", ...conf.handlers, async (req, res, next) => {
try {
const workspaceId = req.query.workspaceId || "*";
const id = req.query.id;
const path = req.query.path || "";
const category = req.query.category || "*";
const analysisResult = await conf.store.loadById(id, true);
if (!analysisResult) {
res.send(`No project at ${JSON.stringify(id)}`);
return;
}
const everyFingerprint = await conf.store.fingerprintsForProject(id);
const virtualPaths = _.uniq(everyFingerprint.map(f => f.path)).filter(p => !!p);
const allFingerprints = everyFingerprint.filter(fp => fp.path === path);
// TODO this is nasty. why query deep in the first place?
analysisResult.analysis.fingerprints = allFingerprints;
const mostRecentTimestamp = allFingerprints.length > 0 ? new Date(Math.max(...allFingerprints.map(fp =>
fp.timestamp.getTime()))) : undefined;
const aspectsAndFingerprints = await projectFingerprints(conf.aspectRegistry,
allFingerprints);
// assign style based on ideal
const ffd: ProjectAspectForDisplay[] = aspectsAndFingerprints.map(aspectAndFingerprints => ({
...aspectAndFingerprints,
fingerprints: aspectAndFingerprints.fingerprints.map(fp => ({
...fp,
idealDisplayString: displayIdeal(fp, aspectAndFingerprints.aspect),
style: displayStyleAccordingToIdeal(fp),
})),
}));
const repo = (await conf.aspectRegistry.tagAndScoreRepos(workspaceId, [analysisResult], { category }))[0];
// TODO nasty
repo.analysis.id.path = path;
res.send(renderStaticReactNode(
RepoExplorer({
repo,
aspects: _.sortBy(ffd.filter(f => !!f.aspect.displayName), f => f.aspect.displayName),
category,
timestamp: mostRecentTimestamp,
virtualPaths,
}), `Repository Insights`,
conf.instanceMetadata));
return;
} catch (e) {
logger.error(e);
next(e);
}
});
}
function exposeExplorePage(conf: WebAppConfig): void {
conf.express.get("/explore", ...conf.handlers, (req, res, next) => {
const tags = req.query.tags || "";
const workspaceId = req.query.workspaceId || "*";
const dataUrl = `/api/v1/${workspaceId}/explore?tags=${tags}`;
const readable = describeSelectedTagsToAnimals(tags.split(","));
return renderDataUrl(conf.instanceMetadata, workspaceId, {
dataUrl,
heading: "Explore repositories by tag",
title: `Repositories matching ${readable}`,
},
conf.aspectRegistry, conf.httpClientFactory, req, res).catch(next);
});
}
function exposeDriftPage(conf: WebAppConfig): void {
conf.express.get("/drift", ...conf.handlers, (req, res, next) => {
const workspaceId = req.query.workspaceId || "*";
const percentile = req.query.percentile || 0;
const type = req.query.type;
const dataUrl = `/api/v1/${workspaceId}/drift` +
`?percentile=${percentile}` +
(!!type ? `&type=${type}` : "");
return renderDataUrl(conf.instanceMetadata, workspaceId, {
dataUrl,
title: "Drift by aspect",
heading: type ?
`Drift across aspect ${type} with entropy above ${percentile}th percentile` :
`Drift across all aspects with entropy above ${percentile}th percentile`,
subheading: "Sizing shows degree of entropy",
},
conf.aspectRegistry, conf.httpClientFactory, req, res).catch(next);
});
}
function exposeFingerprintReportPage(conf: WebAppConfig): void {
conf.express.get("/fingerprint/:type/:name", ...conf.handlers, (req, res, next) => {
const type = req.params.type;
const name = req.params.name;
const otherLabel = req.query.otherLabel;
const aspect = conf.aspectRegistry.aspectOf(type);
if (!aspect) {
res.status(400).send("No aspect found for type " + type);
return;
}
const fingerprintDisplayName = defaultedToDisplayableFingerprintName(aspect)(name);
const workspaceId = req.query.workspaceId || "*";
let dataUrl = `/api/v1/${workspaceId}/fingerprint/${
encodeURIComponent(type)}/${
encodeURIComponent(name)}?byOrg=${
req.query.byOrg === "true"}&progress=${
req.query.progress === "true"}&trim=${
req.query.trim === "true"}`;
if (otherLabel) {
dataUrl += `&otherLabel=${otherLabel}`;
}
renderDataUrl(conf.instanceMetadata, workspaceId, {
dataUrl,
title: `Atomist aspect drift`,
heading: aspect.displayName,
subheading: fingerprintDisplayName,
}, conf.aspectRegistry, conf.httpClientFactory, req, res).catch(next);
});
}
function exposeCustomReportPage(conf: WebAppConfig): void {
conf.express.get("/report/:name", ...conf.handlers, (req, res, next) => {
const name = req.params.name;
const workspaceId = req.query.workspaceId || "*";
const queryString = jsonToQueryString(req.query);
const dataUrl = `/api/v1/${workspaceId}/report/${name}?${queryString}`;
const reporter = CustomReporters[name];
if (!reporter) {
throw new Error(`No report named ${name}`);
}
return renderDataUrl(conf.instanceMetadata, workspaceId, {
dataUrl,
heading: name,
title: reporter.summary,
}, conf.aspectRegistry, conf.httpClientFactory, req, res).catch(next);
});
}
// TODO fix any
async function renderDataUrl(instanceMetadata: ExtensionPackMetadata,
workspaceId: string,
page: {
title: string,
heading: string,
subheading?: string,
dataUrl: string,
},
aspectRegistry: AspectRegistry,
httpClientFactory: HttpClientFactory,
req: any,
res: any): Promise<void> {
let tree: TagTree;
const possibleIdealsForDisplay: PossibleIdealForDisplay[] = [];
const fullUrl = `http://${req.get("host")}${page.dataUrl}`;
try {
const result = await httpClientFactory.create().exchange<TagTree>(fullUrl,
{ retry: { retries: 0 } });
tree = result.body;
logger.info("From %s, got %s", fullUrl, tree.circles.map(c => c.meaning));
} catch (e) {
throw new Error(`Failure fetching sunburst data from ${fullUrl}: ` + e.message);
}
populateLocalURLs(tree);
logger.info("Data url=%s", page.dataUrl);
const fieldsToDisplay = ["entropy", "variants", "count"];
res.send(renderStaticReactNode(
SunburstPage({
workspaceId,
heading: page.heading,
subheading: page.subheading,
currentIdeal: await lookForIdealDisplay(aspectRegistry, req.query.type, req.query.name),
possibleIdeals: possibleIdealsForDisplay,
query: req.params.query,
dataUrl: fullUrl,
tree,
selectedTags: req.query.tags ? req.query.tags.split(",") : [],
fieldsToDisplay,
}),
page.title,
instanceMetadata,
[
"/sunburstScript-bundle.js",
]));
}
export function populateLocalURLs(plantedTree: PlantedTree): void {
visit(plantedTree.tree, (n, level) => {
const circle = plantedTree.circles[level];
if (!circle) {
return true;
}
const d = n as any;
if (circle && circle.meaning === "aspect name") {
if (d.type) {
d.url = `/fingerprint/${encodeURIComponent(d.type)}/*`;
}
}
if (d.fingerprint_name && d.type) {
d.url = `/fingerprint/${encodeURIComponent(d.type)}/${encodeURIComponent(d.fingerprint_name)}`;
}
return true;
});
}
export function jsonToQueryString(json: object): string {
return Object.keys(json).map(key =>
encodeURIComponent(key) + "=" + encodeURIComponent(json[key]),
).join("&");
}
function displayIdeal(fingerprint: AugmentedFingerprintForDisplay, aspect: Aspect): string {
if (idealIsDifferentFromActual(fingerprint)) {
return defaultedToDisplayableFingerprint(aspect)((fingerprint.ideal as ConcreteIdeal).ideal);
}
if (idealIsElimination(fingerprint)) {
return "eliminate";
}
return "";
}
async function lookForIdealDisplay(aspectRegistry: AspectRegistry,
aspectType: string,
fingerprintName: string): Promise<{ displayValue: string } | undefined> {
if (!aspectType) {
return undefined;
}
const aspect = aspectRegistry.aspectOf(aspectType);
if (!aspect) {
return undefined;
}
const ideal = await aspectRegistry.idealStore
.loadIdeal("local", aspectType, fingerprintName);
if (!ideal) {
return undefined;
}
if (!isConcreteIdeal(ideal)) {
return { displayValue: "eliminate" };
}
return { displayValue: defaultedToDisplayableFingerprint(aspect)(ideal.ideal) };
}
function idealIsElimination(fingerprint: AugmentedFingerprintForDisplay): boolean {
return fingerprint.ideal && !isConcreteIdeal(fingerprint.ideal);
}
function idealIsDifferentFromActual(fingerprint: AugmentedFingerprintForDisplay): boolean {
return fingerprint.ideal && isConcreteIdeal(fingerprint.ideal) && fingerprint.ideal.ideal.sha !== fingerprint.sha;
}
function idealIsSameAsActual(fingerprint: AugmentedFingerprintForDisplay): boolean {
return fingerprint.ideal && isConcreteIdeal(fingerprint.ideal) && fingerprint.ideal.ideal.sha === fingerprint.sha;
}
function displayStyleAccordingToIdeal(fingerprint: AugmentedFingerprintForDisplay): CSSProperties {
const redStyle: CSSProperties = { color: "red" };
const greenStyle: CSSProperties = { color: "green" };
if (idealIsSameAsActual(fingerprint)) {
return greenStyle;
}
if (idealIsDifferentFromActual(fingerprint)) {
return redStyle;
}
if (idealIsElimination(fingerprint)) {
return redStyle;
}
return {};
}
export type AugmentedFingerprintForDisplay =
FP &
Pick<ProjectFingerprintForDisplay, "displayValue" | "displayName"> & {
ideal?: Ideal;
};
export interface AugmentedAspectForDisplay {
aspect: Aspect;
fingerprints: AugmentedFingerprintForDisplay[];
}
async function projectFingerprints(fm: AspectRegistry, allFingerprintsInOneProject: FP[]): Promise<AugmentedAspectForDisplay[]> {
const result = [];
for (const aspect of fm.aspects) {
const originalFingerprints =
_.sortBy(allFingerprintsInOneProject.filter(fp => aspect.name === (fp.type || fp.name)), fp => fp.name);
if (originalFingerprints.length > 0) {
const fingerprints: AugmentedFingerprintForDisplay[] = [];
for (const fp of originalFingerprints) {
fingerprints.push({
...fp,
// ideal: await this.opts.idealResolver(fp.name),
displayValue: defaultedToDisplayableFingerprint(aspect)(fp),
displayName: defaultedToDisplayableFingerprintName(aspect)(fp.name),
});
}
result.push({
aspect,
fingerprints,
});
}
}
return result;
}