diff --git a/.gitignore b/.gitignore index 0edba41..4cba755 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ spring-shell.log .idea/* *.log +user/staging diff --git a/acropolis-persistence/src/main/kotlin/org/ephyra/acropolis/persistence/api/persistence/GraphicalAssetPersistence.kt b/acropolis-persistence/src/main/kotlin/org/ephyra/acropolis/persistence/api/persistence/GraphicalAssetPersistence.kt index 03ace06..508a94f 100644 --- a/acropolis-persistence/src/main/kotlin/org/ephyra/acropolis/persistence/api/persistence/GraphicalAssetPersistence.kt +++ b/acropolis-persistence/src/main/kotlin/org/ephyra/acropolis/persistence/api/persistence/GraphicalAssetPersistence.kt @@ -30,4 +30,15 @@ class GraphicalAssetPersistence { fun findAll(): MutableIterable { return repo.findAll() } + + /** + * Find a graphical asset by name + * + * @param name The name of the asset + * @return the asset, if found + */ + fun find(name: String): GraphicalAssetEntity? { + val asset = repo.findByName(name) + return if (asset.isPresent) asset.get() else null + } } diff --git a/acropolis-persistence/src/main/kotlin/org/ephyra/acropolis/persistence/impl/GraphicalAssetRepository.kt b/acropolis-persistence/src/main/kotlin/org/ephyra/acropolis/persistence/impl/GraphicalAssetRepository.kt index 3f31ea9..094f620 100644 --- a/acropolis-persistence/src/main/kotlin/org/ephyra/acropolis/persistence/impl/GraphicalAssetRepository.kt +++ b/acropolis-persistence/src/main/kotlin/org/ephyra/acropolis/persistence/impl/GraphicalAssetRepository.kt @@ -2,8 +2,17 @@ package org.ephyra.acropolis.persistence.impl import org.ephyra.acropolis.persistence.api.entity.GraphicalAssetEntity import org.springframework.data.repository.CrudRepository +import java.util.Optional /** * Repository for storing graphical assets */ -interface GraphicalAssetRepository : CrudRepository +interface GraphicalAssetRepository : CrudRepository { + /** + * Find a graphical asset by name + * + * @param name The name of the asset + * @return the asset, if found + */ + fun findByName(name: String): Optional +} diff --git a/acropolis-report/build.gradle b/acropolis-report/build.gradle new file mode 100644 index 0000000..2d2bc9a --- /dev/null +++ b/acropolis-report/build.gradle @@ -0,0 +1,69 @@ +buildscript { + ext { + kotlinVersion = "1.2.71" + } + repositories { + jcenter() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlinVersion" + } +} + +apply plugin: 'kotlin' +apply plugin: 'jacoco' + +sourceCompatibility = 1.8 +compileKotlin { + kotlinOptions { + freeCompilerArgs = ["-Xjsr305=strict"] + jvmTarget = "1.8" + } +} + +jacoco { + toolVersion = '0.8.2' +} + +jacocoTestReport { + reports { + xml.enabled = true + html.enabled = true + csv.enabled = false + } +} + +jacocoTestCoverageVerification { + violationRules { + rule { + limit { + minimum = 0.9 + } + } + } +} + +test { + useJUnitPlatform() +} + +dependencies { + // The Kotlin standard library + compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + + // https://mvnrepository.com/artifact/org.slf4j/slf4j-api + compile group: 'org.slf4j', name: 'slf4j-api', version: '1.7.25' + + // https://mvnrepository.com/artifact/org.springframework/spring-context + compile group: 'org.springframework', name: 'spring-context', version: '5.1.1.RELEASE' + + // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic + testCompile group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3' + + // https://mvnrepository.com/artifact/io.kotlintest/kotlintest-runner-junit5 + testCompile "io.kotlintest:kotlintest-runner-junit5:3.1.9" + + // https://mvnrepository.com/artifact/io.mockk/mockk + testCompile "io.mockk:mockk:1.8.7" +} diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/IImageSource.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/IImageSource.kt new file mode 100644 index 0000000..4832a88 --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/IImageSource.kt @@ -0,0 +1,16 @@ +package org.ephyra.acropolis.report.api + +import java.io.InputStream + +/** + * Image source to provide images on request to the rendering process. + */ +interface IImageSource { + /** + * Get an image by its resource name + * + * @param resourceName The resource name of the image + * @return An input stream for the image data to be read from + */ + fun get(resourceName: String): InputStream +} diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/IReportRunner.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/IReportRunner.kt new file mode 100644 index 0000000..cfaa22b --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/IReportRunner.kt @@ -0,0 +1,16 @@ +package org.ephyra.acropolis.report.api + +import org.ephyra.acropolis.report.api.model.GraphContainer + +/** + * Interface for a report runner. This interface provides the entry point into this module. + */ +interface IReportRunner { + /** + * Run a report based on the provided model + * + * @param graphContainer The container for the model to build the report from + * @param imageSource Source to allow images to be provided during the rendering process + */ + fun run(graphContainer: GraphContainer, imageSource: IImageSource) +} diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/model/Graph.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/model/Graph.kt new file mode 100644 index 0000000..0d61e4e --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/api/model/Graph.kt @@ -0,0 +1,140 @@ +package org.ephyra.acropolis.report.api.model + +/** + * Container for a graph model. + * Allows metadata to be provided with the model. + */ +class GraphContainer( + val graph: Graph +) { + private val subGraphs: MutableList = ArrayList() + + /** + * Define a named sub-graph of the graph held by this container. + * The provided list of nodes can later be used to extract a sub-graph. + * + * @param name The name of the sub-graph + * @param includeNodes The nodes to include in the sub-graph + */ + fun defineSubgraph(name: String, includeNodes: List) { + subGraphs.add(SubGraphSelector(name, includeNodes)) + } +} + +/** + * Model to represent a graph, in the mathematical sense. + */ +class Graph { + private val nodes: MutableSet = HashSet() + + private val edges: MutableList = ArrayList() + + /** + * Add a node to the graph + * + * @param node The node to add + */ + fun addNode(node: Node) { + nodes.add(node) + } + + /** + * Add an edge between two nodes + * + * @param n1 Node to connect + * @param n2 Node to connect + */ + fun addEdge(n1: Node, n2: Node) { + edges.add(Edge(n1, n2, false)) + } + + /** + * Add a directed edge between two nodes + * + * @param from The source node for the edge + * @param to The sink node for the edge + */ + fun addDirectedEdge(from: Node, to: Node) { + edges.add(Edge(from, to, true)) + } + + /** + * Find a node by its label + * + * @param label The label to search for + * @return The node, if found + */ + fun findNode(label: String): Node? { + return nodes.find { node -> node.label == label } + } + + /** + * Find nodes, N, such that all edges which connect N to the graph, N is the source. + * + * Only valid for di-graphs, otherwise must allow edges if incoming edges have a corresponding outgoing edge. + */ + fun findSourceNodes(): HashSet { + val tempNodes = HashSet() + tempNodes.addAll(nodes) + + edges.forEach { edge -> + val node = edge.sink + tempNodes.remove(node) + } + + return tempNodes + } + + /** + * Return which ever node happens to be first. + * + * @return The first node in the graph, as it is stored + */ + fun firstNode(): Node { + return nodes.first() + } + + /** + * Finds edges such that the specified node is the source, and collects the set of sink nodes + * from these edges. + */ + fun findNodesConnectedFrom(node: Node): HashSet { + val tempNodes = HashSet() + edges.forEach { edge -> + if (edge.source == node) { + tempNodes.add(edge.sink) + } + } + + return tempNodes + } +} + +/** + * Model to represent a node in a graph + */ +class Node( + val label: String, + + val representedByResourceName: String +) + +/** + * Model to represent an edge in a graph + */ +class Edge( + val source: Node, + + val sink: Node, + + val directed: Boolean = false +) + +/** + * Selector for building a sub-graph from a subset of a graph's nodes. + */ +class SubGraphSelector ( + val name: String, + + val includeNodes: List +) diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/config/ReportConfiguration.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/config/ReportConfiguration.kt new file mode 100644 index 0000000..a434dda --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/config/ReportConfiguration.kt @@ -0,0 +1,12 @@ +package org.ephyra.acropolis.report.config + +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration + +/** + * Configuration root for the module. By directing the Spring framework to load this class as configuration + * the entire module will be configured for use. + */ +@Configuration +@ComponentScan(basePackages = ["org.ephyra.acropolis.report.config", "org.ephyra.acropolis.report.impl"]) +open class ReportConfiguration diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/ReportRunner.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/ReportRunner.kt new file mode 100644 index 0000000..738ec14 --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/ReportRunner.kt @@ -0,0 +1,143 @@ +package org.ephyra.acropolis.report.impl + +import org.ephyra.acropolis.report.api.IImageSource +import org.ephyra.acropolis.report.api.IReportRunner +import org.ephyra.acropolis.report.api.model.Graph +import org.ephyra.acropolis.report.api.model.GraphContainer +import org.ephyra.acropolis.report.api.model.Node +import org.ephyra.acropolis.report.impl.render.CardBuilder +import org.ephyra.acropolis.report.impl.render.DiagramRenderer +import org.ephyra.acropolis.report.impl.render.Position2D +import org.ephyra.acropolis.report.impl.render.Size2D +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.io.File +import java.lang.IllegalStateException + +/** + * Implementation of the report runner interface. + */ +@Suppress("MagicNumber") +@Component +internal class ReportRunner : IReportRunner { + private val logger = LoggerFactory.getLogger(ReportRunner::class.java) + + override fun run(graphContainer: GraphContainer, imageSource: IImageSource) { + println("Running report") + val depthMap = buildNodeDepth(graphContainer.graph) + val depthCounts = countDepths(depthMap) + + val maxDepth = depthMap.values.max() ?: throw IllegalStateException("missing depth") + val maxDepthCount = depthCounts.values.max() ?: throw IllegalStateException("missing count") + + val tileWidth = 300 + val tileHeight = 350 + + val cardSeparationHorizontal = 75 + val cardSeparationVertical = 35 + + val diagramPadding = 30 + + val diagramWidth = 2 * diagramPadding + (maxDepth + 1) * tileWidth + maxDepth * cardSeparationHorizontal + val diagramHeight = 2 * diagramPadding + maxDepthCount * tileHeight + + (maxDepthCount - 1) * cardSeparationVertical + + logger.debug("Creating diagram with dimensions [w=$diagramWidth, h=$diagramHeight]") + + val tempDepthCounts = HashMap() + depthCounts.forEach { depth, count -> + tempDepthCounts[depth] = count + } + + val positions = HashMap() + depthMap.forEach { node, depth -> + val currentDepthCount = tempDepthCounts[depth] ?: throw IllegalStateException("missing temp depth count") + + val depthCount = depthCounts[depth] ?: throw IllegalStateException("missing depth count") + + val x = diagramPadding + depth * cardSeparationHorizontal + depth * tileWidth + + // This isn't fantastic. Does exact layout if the column is going to be filled and distributes otherwise. + val y: Double = if (depthCount < maxDepthCount) { + diagramPadding + ((diagramHeight - 2 * diagramPadding) / (depthCount + 1)) * currentDepthCount + - 0.5 * tileHeight + } + else { + (diagramPadding + (currentDepthCount - 1) * tileHeight + + (currentDepthCount - 1) * cardSeparationVertical).toDouble() + } + + val position = Position2D( + x.toFloat(), + y.toFloat() + ) + + logger.debug("Placing node [label=${node.label}] at position [x=$x, y=$y") + + positions[node] = position + + tempDepthCounts[depth] = currentDepthCount - 1 + } + + DiagramRenderer(diagramWidth, diagramHeight).use { renderer -> + positions.forEach { node, position -> + CardBuilder(position, Size2D(tileWidth.toFloat(), tileHeight.toFloat())) + .withImage(imageSource.get(node.representedByResourceName)) + .withLabel(node.label) + .build(renderer) + } + renderer.export(File("test-report.png")) + } + } + + private fun countDepths(depthMap: HashMap): HashMap { + val depthCounts = HashMap() + depthMap.forEach { (_, v) -> + val t = depthCounts[v] + if (t == null) { + depthCounts[v] = 1 + } else { + depthCounts[v] = t + 1 + } + } + return depthCounts + } + + fun buildNodeDepth(graph: Graph): HashMap { + val startNode = pickStartNode(graph) + + val depths = HashMap() + depths[startNode] = 0 + + nodeDepth(startNode, graph, depths, 1) + + return depths + } + + fun nodeDepth(currentNode: Node, graph: Graph, depths: HashMap, depth: Int) { + val connected = graph.findNodesConnectedFrom(currentNode) + + // Remove all the nodes which are already numbered. + connected.removeAll(depths.keys) + + // Number each of the connected nodes. + connected.forEach { con -> + depths[con] = depth + } + + // Now apply the same process to each of the nodes we just numbered. + connected.forEach { con -> + nodeDepth(con, graph, depths, depth + 1) + } + } + + fun pickStartNode(graph: Graph): Node { + val sourceNodes = graph.findSourceNodes() + return if (sourceNodes.isEmpty()) { + graph.firstNode() + } + else { + sourceNodes.first() + } + } +} diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/CardBuilder.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/CardBuilder.kt new file mode 100644 index 0000000..b4fffe1 --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/CardBuilder.kt @@ -0,0 +1,84 @@ +package org.ephyra.acropolis.report.impl.render + +import java.io.InputStream + +/** + * Builder to encapsulate the logic of laying out an image and associated text. + */ +@Suppress("MagicNumber") +class CardBuilder( + private val position2D: Position2D, + + private val size2D: Size2D +) { + private var source: InputStream? = null + private var label: String? = null + + /** + * Configures the image to use for the card being built. + * + * @param source The input stream which will provide the image data + * @return The CardBuilder instance so that method calls can be chained + */ + fun withImage(source: InputStream): CardBuilder { + this.source = source + return this + } + + /** + * Configures the label to use for the card being built. + * + * @param label The label text + * @return The CardBuilder instance so that method calls can be chained + */ + fun withLabel(label: String): CardBuilder { + this.label = label + return this + } + + /** + * Creates the card layout and renders it using the provided renderer. + * + * @param renderer The diagram renderer to use for drawing the card + */ + fun build(renderer: DiagramRenderer) { + renderer.drawOutline(position2D, size2D) + + val imageSource = source + if (imageSource != null) { + drawImage(renderer, imageSource) + } + + val labelText = label + if (labelText != null) { + drawLabel(renderer, labelText) + } + } + + private fun drawLabel(renderer: DiagramRenderer, labelText: String) { + val labelDimensions = renderer.getStringDimensions(labelText) + + val position = Position2D( + (position2D.x + 0.5 * size2D.width - 0.5 * labelDimensions.width).toFloat(), + (position2D.y + 0.9 * size2D.height - 0.5 * labelDimensions.height).toFloat() + ) + + renderer.drawString(labelText, position) + } + + private fun drawImage(renderer: DiagramRenderer, imageSource: InputStream) { + val imageWidthScale = 0.8 + val imageHeightScale = 0.7 + val imagePosition = Position2D( + (position2D.x + size2D.width * (0.5 * (1 - imageWidthScale))).toFloat(), + (position2D.y + size2D.width * (0.5 * (1 - imageWidthScale))).toFloat() + ) + + val imageSize = Size2D( + (size2D.width * imageWidthScale).toFloat(), + (size2D.height * imageHeightScale).toFloat() + ) + + renderer.addImage(imagePosition, imageSize, imageSource) + } +} diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/DiagramRenderer.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/DiagramRenderer.kt new file mode 100644 index 0000000..c84a44a --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/DiagramRenderer.kt @@ -0,0 +1,126 @@ +package org.ephyra.acropolis.report.impl.render + +import java.awt.BasicStroke +import java.awt.Color +import java.awt.Font +import java.awt.Graphics2D +import java.awt.Polygon +import java.awt.Rectangle +import java.awt.Stroke +import java.awt.geom.Rectangle2D +import java.awt.image.BufferedImage +import java.awt.image.BufferedImage.TYPE_INT_RGB +import java.io.File +import java.io.InputStream +import java.lang.IllegalStateException +import javax.imageio.ImageIO + +/** + * Wrapper around Java2D API operations to provide a specific set of operations for use + * in diagram rendering. + */ +@Suppress("MagicNumber") +class DiagramRenderer( + width: Int, + + height: Int +) : AutoCloseable { + private val targetImg: BufferedImage = BufferedImage(width, height, TYPE_INT_RGB) + private val target: Graphics2D + private var font: Font + + init { + target = targetImg.createGraphics() + + target.color = Color.WHITE + target.fillRect(0, 0, width, height) + + font = Font.createFont(Font.TRUETYPE_FONT, File("user/staging/playfair-display/PlayfairDisplay-Regular.ttf")) + font = font.deriveFont(target.font.size * 1.2f) + } + + /** + * Add an image to the diagram at the specified coordinates. + * + * Note that the input size will cause the image to be scaled to that size. + * + * @param position The position of the top left corner of the image from the top left of the draw space. + * @param size The size that the image should be rendered at + * @param source The input stream to read the image from + */ + fun addImage(position: Position2D, size: Size2D, source: InputStream) { + val img = ImageIO.read(source) + target.drawImage(img, position.x.toInt(), position.y.toInt(), size.width.toInt(), size.height.toInt(), null) + } + + /** + * Export the diagram to file + * + * @param outFile The file to write to + */ + fun export(outFile: File) { + ImageIO.write(targetImg, outFile.extension, outFile) + } + + /** + * Draw a connection between two tiles. + */ + fun drawConnection() { + target.stroke = BasicStroke(4f) + target.color = Color.BLUE + target.drawLine(895, 550, 895, 800 - 20) + + target.stroke = BasicStroke(2f) + target.color = Color.YELLOW + val polygon = Polygon() + polygon.addPoint(895, 800) + polygon.addPoint(895 - 20, 800 - 20) + polygon.addPoint(895 + 20, 800 - 20) + target.fill(polygon) + target.drawPolygon(polygon) + } + + /** + * Draw a string of text onto the diagram using the given font. + * + * @param str The text to draw + * @param position The position of the top left corner of the string from the top left of the draw space. + */ + fun drawString(str: String, position: Position2D) { + target.color = Color.DARK_GRAY + target.font = font + target.drawString(str, position.x, position.y) + } + + /** + * Gets the dimensions that the input string will have when drawn, given the current font and + * scaling etc. + * + * @param str The string to get dimensions for + * @return The dimensions of the input string when rendered + */ + fun getStringDimensions(str: String): Size2D { + val fontMetrics = target.getFontMetrics(font) + + val bounds = fontMetrics.getStringBounds(str, target) + + return Size2D(bounds.width.toFloat(), bounds.height.toFloat()) + } + + /** + * Draws an outline, which is a rectangle where only the outline is drawn. + * + * @param position The position to draw the rectangle outline at + * @param size The size of the rectangle to draw + */ + fun drawOutline(position: Position2D, size: Size2D) { + target.background = Color.YELLOW + target.stroke = BasicStroke(1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) + + target.drawRect(position.x.toInt(), position.y.toInt(), size.width.toInt(), size.height.toInt()) + } + + override fun close() { + target.dispose() + } +} diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/Position2D.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/Position2D.kt new file mode 100644 index 0000000..5900abc --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/Position2D.kt @@ -0,0 +1,9 @@ +package org.ephyra.acropolis.report.impl.render + +/** + * Represents a position on a 2D plane + */ +class Position2D( + val x: Float, + val y: Float +) diff --git a/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/Size2D.kt b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/Size2D.kt new file mode 100644 index 0000000..cad0223 --- /dev/null +++ b/acropolis-report/src/main/kotlin/org/ephyra/acropolis/report/impl/render/Size2D.kt @@ -0,0 +1,9 @@ +package org.ephyra.acropolis.report.impl.render + +/** + * A size in 2D space + */ +class Size2D( + val width: Float, + val height: Float +) diff --git a/acropolis-service/build.gradle b/acropolis-service/build.gradle index 9383c3e..e5eb951 100644 --- a/acropolis-service/build.gradle +++ b/acropolis-service/build.gradle @@ -92,6 +92,7 @@ dependencies { compile project(":acropolis-persistence") compile project(":acropolis-external") + compile project(":acropolis-report") // https://mvnrepository.com/artifact/io.kotlintest/kotlintest-runner-junit5 testCompile "io.kotlintest:kotlintest-runner-junit5:3.1.9" diff --git a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/api/IGraphicalAssetService.kt b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/api/IGraphicalAssetService.kt index c5f0b77..d97577f 100644 --- a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/api/IGraphicalAssetService.kt +++ b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/api/IGraphicalAssetService.kt @@ -1,6 +1,7 @@ package org.ephyra.acropolis.service.api import org.ephyra.acropolis.persistence.api.GraphicalAssetType +import org.ephyra.acropolis.persistence.api.entity.GraphicalAssetEntity import org.ephyra.acropolis.service.api.model.GraphicalAsset /** @@ -22,4 +23,12 @@ interface IGraphicalAssetService { * @return list of graphical assets */ fun findAll(): List + + /** + * Find a graphical asset by name + * + * @param name The name of the asset to find + * @return The graphical asset, if found + */ + fun find(name: String): GraphicalAssetEntity? } diff --git a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/api/IReportService.kt b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/api/IReportService.kt new file mode 100644 index 0000000..5607014 --- /dev/null +++ b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/api/IReportService.kt @@ -0,0 +1,14 @@ +package org.ephyra.acropolis.service.api + +/** + * Service for creation reports on a project. + * These may be different formats and contain all or a subset of the data contained in the project. + */ +interface IReportService { + /** + * Create a report which contains details of the software in the given project + * + * @param projectName The name of the project to run the report on + */ + fun runSoftwareReport(projectName: String) +} diff --git a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/config/ServiceConfiguration.kt b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/config/ServiceConfiguration.kt index a65b660..3b56ac4 100644 --- a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/config/ServiceConfiguration.kt +++ b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/config/ServiceConfiguration.kt @@ -8,5 +8,9 @@ import org.springframework.context.annotation.Configuration * in order to configure this module correctly. */ @Configuration -@ComponentScan(basePackages = ["org.ephyra.acropolis.persistence.config", "org.ephyra.acropolis.service.impl"]) +@ComponentScan(basePackages = [ + "org.ephyra.acropolis.persistence.config", + "org.ephyra.acropolis.report.config", + "org.ephyra.acropolis.service.impl" +]) class ServiceConfiguration diff --git a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/GraphicalAssetImageSource.kt b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/GraphicalAssetImageSource.kt new file mode 100644 index 0000000..e3c6034 --- /dev/null +++ b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/GraphicalAssetImageSource.kt @@ -0,0 +1,25 @@ +package org.ephyra.acropolis.service.impl + +import org.ephyra.acropolis.report.api.IImageSource +import org.ephyra.acropolis.service.api.IGraphicalAssetService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.lang.IllegalStateException + +/** + * Implementation of the image source for use with the report module. + * The implementation makes use of the graphical asset service to load requested resources from the database. + */ +@Component +class GraphicalAssetImageSource : IImageSource { + @Autowired + private lateinit var graphicalAssetService: IGraphicalAssetService + + override fun get(resourceName: String): InputStream { + val asset = graphicalAssetService.find(resourceName) + ?: throw IllegalStateException("Missing resource [$resourceName]") + return ByteArrayInputStream(asset.source) + } +} diff --git a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/GraphicalAssetService.kt b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/GraphicalAssetService.kt index 8042d99..ef208a1 100644 --- a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/GraphicalAssetService.kt +++ b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/GraphicalAssetService.kt @@ -32,4 +32,8 @@ class GraphicalAssetService : IGraphicalAssetService { GraphicalAsset(type, assetEntity.source) } } + + override fun find(name: String): GraphicalAssetEntity? { + return persistence.find(name) + } } diff --git a/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/ReportService.kt b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/ReportService.kt new file mode 100644 index 0000000..f46b253 --- /dev/null +++ b/acropolis-service/src/main/kotlin/org/ephyra/acropolis/service/impl/ReportService.kt @@ -0,0 +1,108 @@ +package org.ephyra.acropolis.service.impl + +import org.ephyra.acropolis.external.SystemSoftwareSpecialization +import org.ephyra.acropolis.external.packSystemSpecialization +import org.ephyra.acropolis.persistence.api.ConnectionType +import org.ephyra.acropolis.persistence.api.IConnectable +import org.ephyra.acropolis.persistence.api.entity.ApplicationSoftwareEntity +import org.ephyra.acropolis.persistence.api.entity.DatastoreEntity +import org.ephyra.acropolis.persistence.api.entity.LoadBalancerEntity +import org.ephyra.acropolis.persistence.api.entity.QueueEntity +import org.ephyra.acropolis.persistence.api.entity.ReverseProxyEntity +import org.ephyra.acropolis.persistence.api.entity.SystemSoftwareEntity +import org.ephyra.acropolis.report.api.IReportRunner +import org.ephyra.acropolis.report.api.model.Graph +import org.ephyra.acropolis.report.api.model.GraphContainer +import org.ephyra.acropolis.report.api.model.Node +import org.ephyra.acropolis.service.api.IApplicationSoftwareService +import org.ephyra.acropolis.service.api.IConnectionService +import org.ephyra.acropolis.service.api.IReportService +import org.ephyra.acropolis.service.api.ISystemSoftwareService +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import java.lang.IllegalStateException + +/** + * Report service implementation + */ +@Service +class ReportService : IReportService { + private val logger: Logger = LoggerFactory.getLogger(ApplicationSoftwareService::class.java) + + @Autowired + private lateinit var applicationService: IApplicationSoftwareService + + @Autowired + private lateinit var systemService: ISystemSoftwareService + + @Autowired + private lateinit var connectionService: IConnectionService + + @Autowired + private lateinit var reportRunner: IReportRunner + + @Autowired + private lateinit var graphicalAssetImageSource: GraphicalAssetImageSource + + override fun runSoftwareReport(projectName: String) { + logger.trace("Starting to run software report for project [$projectName]") + + val applications = applicationService.findAll(projectName) + val systems = systemService.findAll(projectName) + + val nodeMap: MutableMap = HashMap() + val graph = Graph() + + applications.forEach { app -> + val node = Node(app.name, "application") + graph.addNode(node) + + nodeMap[app] = node + } + + systems.forEach { system -> + val node = Node(system.name, getRepresentedByResourceNameFromSystem(system)) + graph.addNode(node) + + nodeMap[system] = node + } + + nodeMap.forEach { fromConnectable, fromNode -> + val connections = connectionService.getConnectionsFrom(fromConnectable, ConnectionType.TALKS_TO) + connections.forEach { toConnection -> + val toNode = when (toConnection) { + is SystemSoftwareEntity -> graph.findNode(toConnection.name) + is ApplicationSoftwareEntity -> graph.findNode(toConnection.name) + else -> throw IllegalStateException("Unknown connection type type") + } ?: throw IllegalStateException("Unable to find node to connect to") + + graph.addDirectedEdge(fromNode, toNode) + } + } + + val graphContainer = GraphContainer(graph) + reportRunner.run(graphContainer, graphicalAssetImageSource) + } + + /** + * Gets the default image resource name for a given system. Falls back to the + * default resource names. + */ + private fun getRepresentedByResourceNameFromSystem(system: SystemSoftwareEntity): String { + // TODO this is bootstrap data and should be extracted. + return if (system.specialization == null) { + "system" + } + else { + when (system.specialization) { + is LoadBalancerEntity -> "load-balancer" + is DatastoreEntity -> "datastore" + is QueueEntity -> "queue" + is ReverseProxyEntity -> "reverse-proxy" + else -> "system" + } + } + } +} diff --git a/acropolis-shell/src/main/kotlin/org/ephyra/acropolis/shell/BootstrapCommand.kt b/acropolis-shell/src/main/kotlin/org/ephyra/acropolis/shell/BootstrapCommand.kt new file mode 100644 index 0000000..f73fd6e --- /dev/null +++ b/acropolis-shell/src/main/kotlin/org/ephyra/acropolis/shell/BootstrapCommand.kt @@ -0,0 +1,70 @@ +package org.ephyra.acropolis.shell + +import org.ephyra.acropolis.persistence.api.GraphicalAssetType +import org.ephyra.acropolis.service.api.IGraphicalAssetService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.shell.standard.ShellComponent +import org.springframework.shell.standard.ShellMethod +import java.io.File + +/** + * Bootstraps the acropolis system. + * Should be run once when the program is set up to seed the database etc. + */ +@ShellComponent +class BootstrapCommand { + @Autowired + private lateinit var graphicalAssetService: IGraphicalAssetService + + /** + * Implementation of the bootstrap command + */ + @ShellMethod("Bootstrap Acropolis") + fun bootstrap() { + println("Bootstrapping the acropolis system.") + + importGraphicalAssets() + + println("Done. Acropolis is now ready to use!") + } + + private fun importGraphicalAssets() { + println("Importing graphical assets") + + graphicalAssetService.create( + "load-balancer", + File("user/graphics/load-balancer.png").readBytes(), + GraphicalAssetType.PNG + ) + + graphicalAssetService.create( + "reverse-proxy", + File("user/graphics/reverse-proxy.png").readBytes(), + GraphicalAssetType.PNG + ) + + graphicalAssetService.create( + "queue", + File("user/graphics/queue.png").readBytes(), + GraphicalAssetType.PNG + ) + + graphicalAssetService.create( + "datastore", + File("user/graphics/datastore.png").readBytes(), + GraphicalAssetType.PNG + ) + + graphicalAssetService.create( + "application", + File("user/graphics/application.png").readBytes(), + GraphicalAssetType.PNG + ) + + graphicalAssetService.create( + "system", + File("user/graphics/system.png").readBytes(), + GraphicalAssetType.PNG + ) + } +} diff --git a/settings.gradle b/settings.gradle index ceae395..098e6bd 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,4 @@ rootProject.name = "Acropolis" -include "acropolis-shell", "acropolis-service", "acropolis-persistence", "acropolis-rest", "acropolis-external" +include "acropolis-shell", "acropolis-service", "acropolis-persistence", "acropolis-rest", + "acropolis-external", "acropolis-report" diff --git a/user/graphics/application.png b/user/graphics/application.png new file mode 100644 index 0000000..c99aaa8 Binary files /dev/null and b/user/graphics/application.png differ diff --git a/user/graphics/application.svg b/user/graphics/application.svg new file mode 100644 index 0000000..823114b --- /dev/null +++ b/user/graphics/application.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/user/graphics/datastore.png b/user/graphics/datastore.png new file mode 100644 index 0000000..41b90d5 Binary files /dev/null and b/user/graphics/datastore.png differ diff --git a/user/graphics/datastore.svg b/user/graphics/datastore.svg new file mode 100644 index 0000000..33a1883 --- /dev/null +++ b/user/graphics/datastore.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/user/graphics/load-balancer.png b/user/graphics/load-balancer.png new file mode 100644 index 0000000..e7cd197 Binary files /dev/null and b/user/graphics/load-balancer.png differ diff --git a/user/graphics/load-balancer.svg b/user/graphics/load-balancer.svg new file mode 100644 index 0000000..e30832f --- /dev/null +++ b/user/graphics/load-balancer.svg @@ -0,0 +1,112 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/user/graphics/queue.png b/user/graphics/queue.png new file mode 100644 index 0000000..f647e74 Binary files /dev/null and b/user/graphics/queue.png differ diff --git a/user/graphics/queue.svg b/user/graphics/queue.svg new file mode 100644 index 0000000..e5acff3 --- /dev/null +++ b/user/graphics/queue.svg @@ -0,0 +1,135 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/user/graphics/reverse-proxy.png b/user/graphics/reverse-proxy.png new file mode 100644 index 0000000..e7cd197 Binary files /dev/null and b/user/graphics/reverse-proxy.png differ diff --git a/user/graphics/reverse-proxy.svg b/user/graphics/reverse-proxy.svg new file mode 100644 index 0000000..80822fc --- /dev/null +++ b/user/graphics/reverse-proxy.svg @@ -0,0 +1,112 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/user/graphics/system.png b/user/graphics/system.png new file mode 100644 index 0000000..03b6ce0 Binary files /dev/null and b/user/graphics/system.png differ diff --git a/user/graphics/system.svg b/user/graphics/system.svg new file mode 100644 index 0000000..7651c51 --- /dev/null +++ b/user/graphics/system.svg @@ -0,0 +1,92 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + +