UNPKG

@atomist/sdm-pack-aspect

Version:

an Atomist SDM Extension Pack for visualizing drift across an organization

433 lines (392 loc) 16.4 kB
/* * 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; }