├── project ├── build.properties └── plugins.sbt ├── screenshot.png ├── shared └── shared │ └── src │ └── main │ └── scala │ └── model │ ├── package.scala │ ├── DockerData.scala │ └── ContainerData.scala ├── server └── src │ ├── main │ └── scala │ │ └── server │ │ ├── StatsData.scala │ │ ├── ProcessesData.scala │ │ ├── package.scala │ │ ├── RawDataStream.scala │ │ ├── DockerProcesses.scala │ │ ├── StatsDataStream.scala │ │ ├── ProcessesDataStream.scala │ │ ├── DockerStats.scala │ │ ├── App.scala │ │ ├── Processes.scala │ │ ├── Service.scala │ │ ├── Stats.scala │ │ └── DockerDataStream.scala │ └── test │ └── scala │ └── server │ ├── StatsSuite.scala │ ├── ProcessesSuite.scala │ ├── ServiceSuite.scala │ ├── ProcessesDataStreamSuite.scala │ ├── StatsDataStreamSuite.scala │ └── DockerIntegrationSuite.scala ├── client └── src │ ├── main │ └── scala │ │ └── client │ │ ├── LineChart.scala │ │ ├── ChartState.scala │ │ ├── Charting.scala │ │ ├── DOM.scala │ │ ├── chartist │ │ ├── Chartist.scala │ │ ├── Axis.scala │ │ ├── Data.scala │ │ └── Options.scala │ │ ├── impl │ │ ├── ChartingImpl.scala │ │ ├── LineChartImpl.scala │ │ └── DOMImpl.scala │ │ ├── Main.scala │ │ ├── ClientState.scala │ │ ├── WebsocketStream.scala │ │ └── Client.scala │ └── test │ └── scala │ └── client │ ├── TestLineChart.scala │ ├── TestCharting.scala │ ├── TestDOM.scala │ ├── chartist │ └── DataSuite.scala │ ├── ClientStateSuite.scala │ └── ClientSuite.scala ├── .gitignore ├── .scalafmt.conf ├── reflect-config.json ├── .github └── workflows │ ├── docker-build.yml │ ├── sbt-scalafmt.yml │ ├── sbt-test.yml │ └── docker-push.yml ├── static ├── css │ └── main.css └── html │ └── index.html ├── README.md ├── Dockerfile └── LICENSE /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.6.0 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vasilmkd/docker-stats-monitor/HEAD/screenshot.png -------------------------------------------------------------------------------- /shared/shared/src/main/scala/model/package.scala: -------------------------------------------------------------------------------- 1 | package object model { 2 | type DockerData = Set[ContainerData] 3 | } 4 | -------------------------------------------------------------------------------- /server/src/main/scala/server/StatsData.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | object StatsData { 4 | val empty: StatsData = Map.empty 5 | } 6 | -------------------------------------------------------------------------------- /client/src/main/scala/client/LineChart.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | trait LineChart[F[_]] { 4 | def update(v: Double): F[Unit] 5 | } 6 | -------------------------------------------------------------------------------- /server/src/main/scala/server/ProcessesData.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | object ProcessesData { 4 | val empty: ProcessesData = Map.empty 5 | } 6 | -------------------------------------------------------------------------------- /shared/shared/src/main/scala/model/DockerData.scala: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | object DockerData { 4 | val empty: DockerData = Set.empty 5 | } 6 | -------------------------------------------------------------------------------- /client/src/main/scala/client/ChartState.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | final case class ChartState[F[_]](cpuChart: LineChart[F], memChart: LineChart[F]) 4 | -------------------------------------------------------------------------------- /server/src/main/scala/server/package.scala: -------------------------------------------------------------------------------- 1 | package object server { 2 | type StatsData = Map[String, Stats] 3 | type ProcessesData = Map[String, Processes] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | logs 3 | target 4 | .classpath 5 | .project 6 | .settings 7 | .bsp 8 | .idea 9 | .vscode 10 | .bloop 11 | .metals 12 | metals.sbt 13 | client.js 14 | client.js.map 15 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "2.6.4" 2 | align.preset = most 3 | maxColumn = 120 4 | continuationIndent.defnSite = 2 5 | spaces.inImportCurlyBraces = true 6 | rewrite.rules = [RedundantBraces, RedundantParens, SortImports, SortModifiers] 7 | -------------------------------------------------------------------------------- /client/src/test/scala/client/TestLineChart.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import cats.Applicative 4 | 5 | class TestLineChart[F[_]: Applicative] extends LineChart[F] { 6 | override def update(v: Double): F[Unit] = 7 | Applicative[F].unit 8 | } 9 | -------------------------------------------------------------------------------- /reflect-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "java.lang.invoke.VarHandle", 4 | "methods": [ 5 | { 6 | "name": "releaseFence", 7 | "parameterTypes": [] 8 | } 9 | ] 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.9.0") 2 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.1.0") 3 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.7") 4 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.5") 5 | -------------------------------------------------------------------------------- /client/src/main/scala/client/Charting.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import client.chartist.Options 4 | 5 | trait Charting[F[_]] { 6 | def createLineChart(selector: String, initial: Double, options: Options): F[LineChart[F]] 7 | } 8 | 9 | object Charting { 10 | def apply[F[_]: Charting]: Charting[F] = implicitly 11 | } 12 | -------------------------------------------------------------------------------- /client/src/main/scala/client/DOM.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import model._ 4 | 5 | trait DOM[F[_]] { 6 | def onAdded(cd: ContainerData): F[Unit] 7 | def onRemoved(id: String): F[Unit] 8 | def onUpdated(cd: ContainerData): F[Unit] 9 | } 10 | 11 | object DOM { 12 | def apply[F[_]: DOM]: DOM[F] = implicitly 13 | } 14 | -------------------------------------------------------------------------------- /client/src/main/scala/client/chartist/Chartist.scala: -------------------------------------------------------------------------------- 1 | package client.chartist 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSGlobal 5 | 6 | @js.native 7 | @JSGlobal 8 | object Chartist extends js.Object { 9 | 10 | @js.native 11 | class Line(selector: String, data: Data, options: Options) extends js.Object { 12 | def update(data: Data): Unit = js.native 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /shared/shared/src/main/scala/model/ContainerData.scala: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | final case class ContainerData( 4 | id: String, 5 | name: String, 6 | image: String, 7 | runningFor: String, 8 | status: String, 9 | cpuPercentage: Double, 10 | memUsage: String, 11 | memPercentage: Double, 12 | netIO: String, 13 | blockIO: String, 14 | pids: Int, 15 | size: String, 16 | ports: String 17 | ) 18 | -------------------------------------------------------------------------------- /client/src/main/scala/client/chartist/Axis.scala: -------------------------------------------------------------------------------- 1 | package client.chartist 2 | 3 | import scala.scalajs.js 4 | 5 | trait Axis extends js.Object { 6 | val showLabel: Boolean 7 | val showGrid: Boolean 8 | } 9 | 10 | object Axis { 11 | def apply(showLabel: Boolean = false, showGrid: Boolean = false): Axis = 12 | js.Dynamic.literal("showLabel" -> showLabel, "showGrid" -> showGrid).asInstanceOf[Axis] 13 | } 14 | -------------------------------------------------------------------------------- /client/src/test/scala/client/TestCharting.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import cats.Applicative 4 | import client.chartist.Options 5 | 6 | object TestCharting { 7 | def apply[F[_]: Applicative]: Charting[F] = 8 | new Charting[F] { 9 | override def createLineChart(selector: String, initial: Double, options: Options): F[LineChart[F]] = 10 | Applicative[F].pure(new TestLineChart[F]) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/main/scala/client/impl/ChartingImpl.scala: -------------------------------------------------------------------------------- 1 | package client.impl 2 | 3 | import cats.effect.Sync 4 | 5 | import client._ 6 | import client.chartist.Options 7 | 8 | object ChartingImpl { 9 | def apply[F[_]: Sync]: Charting[F] = 10 | new Charting[F] { 11 | override def createLineChart(selector: String, initial: Double, options: Options): F[LineChart[F]] = 12 | LineChartImpl.of[F](selector, initial, options) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker image 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | docker_build: 9 | name: Docker build 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | - name: Build Docker image 15 | uses: docker/build-push-action@v2 16 | with: 17 | repository: vasilvasilev97/docker-stats-monitor 18 | push: false 19 | -------------------------------------------------------------------------------- /client/src/main/scala/client/Main.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import cats.effect.{ ExitCode, IO, IOApp } 4 | 5 | import client.impl._ 6 | 7 | object Main extends IOApp { 8 | 9 | implicit private val ioDom = DOMImpl[IO] 10 | implicit private val ioCharting = ChartingImpl[IO] 11 | 12 | def run(args: List[String]): IO[ExitCode] = 13 | WebsocketStream 14 | .stream[IO] 15 | .through(new Client[IO].run) 16 | .compile 17 | .drain 18 | .as(ExitCode.Success) 19 | } 20 | -------------------------------------------------------------------------------- /client/src/test/scala/client/TestDOM.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import cats.Applicative 4 | import model.ContainerData 5 | 6 | object TestDOM { 7 | def apply[F[_]: Applicative]: DOM[F] = 8 | new DOM[F] { 9 | override def onAdded(cd: ContainerData): F[Unit] = 10 | Applicative[F].unit 11 | override def onRemoved(id: String): F[Unit] = 12 | Applicative[F].unit 13 | override def onUpdated(cd: ContainerData): F[Unit] = 14 | Applicative[F].unit 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/src/main/scala/server/RawDataStream.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.InputStream 4 | 5 | import cats.effect.{ Blocker, ContextShift, Sync } 6 | import fs2.{ text, Stream } 7 | import fs2.io._ 8 | 9 | private object RawDataStream { 10 | 11 | def stream[F[_]: Sync: ContextShift](fis: F[InputStream], blocker: Blocker): Stream[F, String] = 12 | readInputStream(fis, 8192, blocker) 13 | .through(text.utf8Decode) 14 | .through(text.lines) 15 | .filter(_.nonEmpty) 16 | .drop(1) 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/sbt-scalafmt.yml: -------------------------------------------------------------------------------- 1 | name: Check style 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | check_style: 9 | name: Scalafmt check style 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | - name: Setup JDK 11 15 | uses: actions/setup-java@v2 16 | with: 17 | distribution: "adopt" 18 | java-version: "11" 19 | - name: Check style 20 | run: sbt scalafmtSbtCheck scalafmtCheckAll 21 | -------------------------------------------------------------------------------- /server/src/main/scala/server/DockerProcesses.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.InputStream 4 | 5 | import cats.effect.Sync 6 | 7 | object DockerProcesses { 8 | 9 | def input[F[_]: Sync]: F[InputStream] = 10 | Sync[F].delay(psBuilder.start().getInputStream()) 11 | 12 | private val psBuilder = new ProcessBuilder() 13 | .command( 14 | "docker", 15 | "ps", 16 | "-a", 17 | "--format", 18 | "table {{.ID}},,,{{.Image}},,,{{.RunningFor}},,,{{.Ports}},,,{{.Status}},,,{{.Size}}" 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /client/src/main/scala/client/chartist/Data.scala: -------------------------------------------------------------------------------- 1 | package client.chartist 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.JSConverters._ 5 | 6 | class Data(initial: Double) extends js.Object { 7 | val labels: js.Array[Int] = Array.range(0, 30).toJSArray 8 | val series: js.Array[js.Array[Double]] = { 9 | val array = new Array[Double](30) 10 | array(29) = initial 11 | js.Array(array.toJSArray) 12 | } 13 | 14 | def add(v: Double): Unit = { 15 | series(0).sliceInPlace(1, 30) 16 | series(0).push(v) 17 | () 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/src/main/scala/server/StatsDataStream.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.InputStream 4 | 5 | import cats.effect.{ Blocker, ContextShift, Sync } 6 | import cats.syntax.all._ 7 | import fs2.Stream 8 | 9 | object StatsDataStream { 10 | 11 | def stream[F[_]: Sync: ContextShift](fis: F[InputStream], blocker: Blocker): Stream[F, StatsData] = 12 | RawDataStream 13 | .stream(fis, blocker) 14 | .evalMap(Stats.parseCSV[F](_)) 15 | .filter(_.id =!= "") 16 | .fold(StatsData.empty)((map, stats) => map + (stats.id -> stats)) 17 | } 18 | -------------------------------------------------------------------------------- /server/src/main/scala/server/ProcessesDataStream.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.InputStream 4 | 5 | import cats.effect.{ Blocker, ContextShift, Sync } 6 | import cats.syntax.all._ 7 | import fs2.Stream 8 | 9 | object ProcessesDataStream { 10 | 11 | def stream[F[_]: Sync: ContextShift](fis: F[InputStream], blocker: Blocker): Stream[F, ProcessesData] = 12 | RawDataStream 13 | .stream(fis, blocker) 14 | .evalMap(Processes.parseCSV[F](_)) 15 | .filter(_.id =!= "") 16 | .fold(ProcessesData.empty)((map, ps) => map + (ps.id -> ps)) 17 | } 18 | -------------------------------------------------------------------------------- /server/src/main/scala/server/DockerStats.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.InputStream 4 | 5 | import cats.effect.Sync 6 | 7 | object DockerStats { 8 | 9 | def input[F[_]: Sync]: F[InputStream] = 10 | Sync[F].delay(statsBuilder.start().getInputStream()) 11 | 12 | private val statsBuilder = new ProcessBuilder() 13 | .command( 14 | "docker", 15 | "stats", 16 | "-a", 17 | "--format", 18 | "table {{.ID}},,,{{.Name}},,,{{.CPUPerc}},,,{{.MemUsage}},,,{{.MemPerc}},,,{{.NetIO}},,,{{.BlockIO}},,,{{.PIDs}}", 19 | "--no-stream" 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /client/src/main/scala/client/impl/LineChartImpl.scala: -------------------------------------------------------------------------------- 1 | package client.impl 2 | 3 | import cats.effect.Sync 4 | 5 | import client.LineChart 6 | import client.chartist._ 7 | import client.chartist.Chartist.Line 8 | 9 | object LineChartImpl { 10 | def of[F[_]: Sync](selector: String, initial: Double, options: Options): F[LineChart[F]] = 11 | Sync[F].delay { 12 | val data = new Data(initial) 13 | val chart = new Line(selector, data, options) 14 | new LineChart[F] { 15 | override def update(v: Double): F[Unit] = 16 | Sync[F].delay { 17 | data.add(v) 18 | chart.update(data) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/sbt-test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | test: 9 | name: Run tests 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | - name: Setup Docker 15 | uses: docker-practice/actions-setup-docker@1.0.4 16 | - name: Setup JDK 11 17 | uses: actions/setup-java@v2 18 | with: 19 | distribution: "adopt" 20 | java-version: "11" 21 | - name: Setup Node.js environment 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: 14 25 | - name: Run tests 26 | run: sbt test 27 | -------------------------------------------------------------------------------- /.github/workflows/docker-push.yml: -------------------------------------------------------------------------------- 1 | name: Push Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | push: 10 | name: Docker push 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v1 17 | - name: Login to DockerHub 18 | uses: docker/login-action@v1 19 | with: 20 | username: ${{ secrets.DOCKER_USERNAME }} 21 | password: ${{ secrets.DOCKER_PASSWORD }} 22 | - name: Build and push 23 | uses: docker/build-push-action@v2 24 | with: 25 | context: . 26 | pull: true 27 | push: true 28 | tags: vasilvasilev97/docker-stats-monitor:latest 29 | -------------------------------------------------------------------------------- /client/src/main/scala/client/chartist/Options.scala: -------------------------------------------------------------------------------- 1 | package client.chartist 2 | 3 | import scala.scalajs.js 4 | 5 | trait Options extends js.Object { 6 | val low: js.Any 7 | val showArea: Boolean 8 | val showPoint: Boolean 9 | val fullWidth: Boolean 10 | val axisX: Axis 11 | val axisY: Axis 12 | } 13 | 14 | object Options { 15 | def apply( 16 | low: js.Any = js.undefined, 17 | showArea: Boolean = true, 18 | showPoint: Boolean = false, 19 | fullWidth: Boolean = true, 20 | axisX: Axis = Axis(), 21 | axisY: Axis = Axis(true, true) 22 | ): Options = 23 | js.Dynamic 24 | .literal( 25 | "low" -> low, 26 | "showArea" -> showArea, 27 | "showPoint" -> showPoint, 28 | "fullWidth" -> fullWidth, 29 | "axisX" -> axisX, 30 | "axisY" -> axisY 31 | ) 32 | .asInstanceOf[Options] 33 | } 34 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Roboto', sans-serif; 3 | } 4 | 5 | :root { 6 | --mdc-theme-primary: #065eac; 7 | } 8 | 9 | body { 10 | margin: 0; 11 | } 12 | 13 | .label { 14 | margin-top: 0; 15 | margin-bottom: 0; 16 | } 17 | 18 | hr { 19 | margin-bottom: 16px; 20 | } 21 | 22 | .mdc-layout-grid { 23 | max-width: 1440px; 24 | } 25 | 26 | .mdc-layout-grid__inner { 27 | margin-bottom: 16px; 28 | } 29 | 30 | .mdc-card { 31 | padding-top: 16px; 32 | padding-left: 16px; 33 | padding-right: 16px; 34 | } 35 | 36 | .ct-chart { 37 | margin-top: 16px; 38 | height: 320px; 39 | } 40 | 41 | .button-div { 42 | text-align: center; 43 | padding: 8px; 44 | } 45 | 46 | .hidden { 47 | display: none; 48 | } 49 | 50 | .ct-series-a .ct-line { 51 | stroke: #00357c; 52 | } 53 | 54 | .ct-series-a .ct-area { 55 | fill: #00357c; 56 | } 57 | -------------------------------------------------------------------------------- /server/src/main/scala/server/App.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import scala.concurrent.ExecutionContext 4 | 5 | import cats.effect.{ Blocker, ExitCode, IO, IOApp } 6 | import fs2.concurrent.Topic 7 | import org.http4s.implicits._ 8 | import org.http4s.blaze.server.BlazeServerBuilder 9 | 10 | import model._ 11 | 12 | object App extends IOApp { 13 | 14 | def run(args: List[String]): IO[ExitCode] = 15 | Blocker[IO] 16 | .use { blocker => 17 | for { 18 | topic <- Topic[IO, DockerData](DockerData.empty) 19 | _ <- DockerDataStream.stream[IO](blocker).through(topic.publish).compile.drain.start 20 | _ <- BlazeServerBuilder[IO](ExecutionContext.global) 21 | .bindHttp(8080, "0.0.0.0") 22 | .withHttpApp(new Service(blocker, topic).routes.orNotFound) 23 | .serve 24 | .compile 25 | .drain 26 | } yield () 27 | } 28 | .as(ExitCode.Success) 29 | } 30 | -------------------------------------------------------------------------------- /client/src/main/scala/client/ClientState.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import cats.MonadError 4 | 5 | import model._ 6 | 7 | final case class ClientState[F[_]: MonadError[*[_], Throwable]](private[client] val map: Map[String, ChartState[F]]) { 8 | 9 | def +(pair: (String, ChartState[F])): ClientState[F] = 10 | ClientState(map + pair) 11 | 12 | def -(key: String): ClientState[F] = 13 | ClientState(map - key) 14 | 15 | def get(id: String): F[ChartState[F]] = 16 | MonadError[F, Throwable].catchNonFatal(map(id)) 17 | 18 | def partition(data: DockerData): ClientState.Partition = { 19 | val keySet = map.keySet 20 | val containerIds = data.map(_.id) 21 | 22 | val removed = keySet.diff(containerIds) 23 | val added = containerIds.diff(keySet) 24 | val updated = keySet.intersect(containerIds) 25 | 26 | ClientState.Partition(removed, added, updated) 27 | } 28 | } 29 | 30 | object ClientState { 31 | case class Partition(removed: Set[String], added: Set[String], updated: Set[String]) 32 | 33 | def empty[F[_]: MonadError[*[_], Throwable]]: ClientState[F] = ClientState(Map.empty) 34 | } 35 | -------------------------------------------------------------------------------- /client/src/main/scala/client/WebsocketStream.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import cats.Show 4 | import cats.effect.{ ConcurrentEffect, Effect, IO, Sync } 5 | import cats.syntax.all._ 6 | import fs2.Stream 7 | import fs2.concurrent.Queue 8 | import io.circe.generic.auto._ 9 | import io.circe.parser._ 10 | import org.scalajs.dom._ 11 | 12 | import model._ 13 | 14 | object WebsocketStream { 15 | 16 | def stream[F[_]: ConcurrentEffect]: Stream[F, DockerData] = 17 | Stream 18 | .eval { 19 | for { 20 | queue <- Queue.circularBuffer[F, MessageEvent](1) 21 | host <- Sync[F].delay(window.location.host) 22 | ws <- Sync[F].delay(new WebSocket(s"ws://$host/ws")) 23 | _ <- Sync[F].delay(ws.onmessage = e => Effect[F].runAsync(queue.enqueue1(e))(_ => IO.unit).unsafeRunSync()) 24 | } yield queue 25 | } 26 | .flatMap(_.dequeue) 27 | .map(_.data.show) 28 | .evalMap(decodeDockerData[F]) 29 | 30 | private def decodeDockerData[F[_]: Sync](json: String): F[DockerData] = 31 | Sync[F].fromEither(decode[DockerData](json)) 32 | 33 | implicit private val anyShow: Show[Any] = 34 | Show.fromToString 35 | } 36 | -------------------------------------------------------------------------------- /server/src/test/scala/server/StatsSuite.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import munit.FunSuite 4 | 5 | class StatsSuite extends FunSuite { 6 | 7 | test("parse") { 8 | val line = 9 | "ed4e5c72308a-everythingelseistruncated,,,affectionate_shockley,,,0.00%,,,1.148MiB / 1.944GiB,,,0.06%,,,1.45kB / 0B,,,0B / 0B,,,1" 10 | val result = Stats( 11 | "ed4e5c72308a", 12 | "affectionate_shockley", 13 | 0.00, 14 | "1.148MiB / 1.944GiB", 15 | 0.06, 16 | "1.45kB / 0B", 17 | "0B / 0B", 18 | 1 19 | ) 20 | assertEquals(Stats.parseCSV[Either[Throwable, *]](line), Right(result)) 21 | } 22 | 23 | test("less punishing parsing") { 24 | val line = 25 | "short,,,affectionate_shockley,,,--,,,1.148MiB / 1.944GiB,,,--,,,1.45kB / 0B,,,0B / 0B,,,--" 26 | val result = Stats( 27 | "", 28 | "affectionate_shockley", 29 | 0.0, 30 | "1.148MiB / 1.944GiB", 31 | 0.0, 32 | "1.45kB / 0B", 33 | "0B / 0B", 34 | 0 35 | ) 36 | assertEquals(Stats.parseCSV[Either[Throwable, *]](line), Right(result)) 37 | } 38 | 39 | test("fail") { 40 | assert(Stats.parseCSV[Either[Throwable, *]]("").isLeft) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/src/main/scala/server/Processes.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import scala.util.Try 4 | 5 | import cats.{ Applicative, MonadError } 6 | import cats.syntax.all._ 7 | 8 | final case class Processes( 9 | id: String, 10 | image: String, 11 | created: String, 12 | ports: String, 13 | status: String, 14 | size: String 15 | ) 16 | 17 | object Processes { 18 | def parseCSV[F[_]: MonadError[*[_], Throwable]](line: String): F[Processes] = 19 | for { 20 | parts <- splitLine[F](line) 21 | processes <- parseParts[F](parts) 22 | } yield processes 23 | 24 | private def splitLine[F[_]: MonadError[*[_], Throwable]](line: String): F[Array[String]] = 25 | for { 26 | parts <- Applicative[F].pure(line.split(",,,")) 27 | _ <- MonadError[F, Throwable] 28 | .raiseError(new IllegalStateException(s"Invalid docker ps data csv: $line")) 29 | .whenA(parts.length < 6) 30 | } yield parts 31 | 32 | private def parseParts[F[_]: MonadError[*[_], Throwable]](parts: Array[String]): F[Processes] = 33 | MonadError[F, Throwable].catchNonFatal { 34 | val id = Try(parts(0).substring(0, 12)).toOption.getOrElse("") 35 | Processes(id, parts(1), parts(2), parts(3), parts(4), parts(5)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/src/test/scala/server/ProcessesSuite.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import munit.FunSuite 4 | 5 | class ProcessesSuite extends FunSuite { 6 | 7 | test("parse") { 8 | val line = 9 | "8c2f7598fc14-everythingelseistruncated,,,cassandra:3.11.6,,,3 hours ago,,,7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp,,,Up 3 hours,,,884kB (virtual 379MB)" 10 | val result = Processes( 11 | "8c2f7598fc14", 12 | "cassandra:3.11.6", 13 | "3 hours ago", 14 | "7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp", 15 | "Up 3 hours", 16 | "884kB (virtual 379MB)" 17 | ) 18 | assertEquals(Processes.parseCSV[Either[Throwable, *]](line), Right(result)) 19 | } 20 | 21 | test("invalid id") { 22 | val line = 23 | "short,,,cassandra:3.11.6,,,3 hours ago,,,7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp,,,Up 3 hours,,,884kB (virtual 379MB)" 24 | val result = Processes( 25 | "", 26 | "cassandra:3.11.6", 27 | "3 hours ago", 28 | "7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp", 29 | "Up 3 hours", 30 | "884kB (virtual 379MB)" 31 | ) 32 | assertEquals(Processes.parseCSV[Either[Throwable, *]](line), Right(result)) 33 | } 34 | 35 | test("fail") { 36 | assert(Processes.parseCSV[Either[Throwable, *]]("").isLeft) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/test/scala/client/chartist/DataSuite.scala: -------------------------------------------------------------------------------- 1 | package client.chartist 2 | 3 | import munit.FunSuite 4 | 5 | class DataSuite extends FunSuite { 6 | 7 | test("initialize") { 8 | val data = new Data(1.0) 9 | assertEquals(data.labels.toList.length, 30) 10 | assertEquals(data.labels.toList, List.range(0, 30)) 11 | assertEquals(data.series(0).toList.length, 30) 12 | assertEquals(data.series(0).toList, List.fill(29)(0.0) :+ 1.0) 13 | } 14 | 15 | test("add") { 16 | val data = new Data(1.0) 17 | assertEquals(data.labels.toList.length, 30) 18 | assertEquals(data.labels.toList, List.range(0, 30)) 19 | assertEquals(data.series(0).toList.length, 30) 20 | assertEquals(data.series(0).toList, List.fill(29)(0.0) :+ 1.0) 21 | data.add(2.0) 22 | assertEquals(data.labels.toList.length, 30) 23 | assertEquals(data.labels.toList, List.range(0, 30)) 24 | assertEquals(data.series(0).toList.length, 30) 25 | assertEquals(data.series(0).toList, List.fill(28)(0.0) ++ List(1.0, 2.0)) 26 | data.add(3.0) 27 | assertEquals(data.labels.toList.length, 30) 28 | assertEquals(data.labels.toList, List.range(0, 30)) 29 | assertEquals(data.series(0).toList.length, 30) 30 | assertEquals(data.series(0).toList, List.fill(27)(0.0) ++ List(1.0, 2.0, 3.0)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/src/test/scala/server/ServiceSuite.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.{ File, FileInputStream } 4 | 5 | import cats.effect.{ Blocker, IO } 6 | import fs2.concurrent.Topic 7 | import fs2.io._ 8 | import fs2.text 9 | import munit.CatsEffectSuite 10 | import org.http4s.{ HttpVersion, Method, Request, Status } 11 | import org.http4s.implicits._ 12 | 13 | import model._ 14 | 15 | class ServiceSuite extends CatsEffectSuite { 16 | 17 | private val blocker = ResourceFixture(Blocker[IO]) 18 | 19 | blocker.test("root") { blocker => 20 | val req = Request[IO](Method.GET, uri"/") 21 | for { 22 | topic <- Topic[IO, DockerData](DockerData.empty) 23 | res <- new Service[IO](blocker, topic).routes.orNotFound.run(req) 24 | _ <- IO(assertEquals(res.status, Status.Ok)) 25 | _ <- IO(assertEquals(res.httpVersion, HttpVersion.`HTTP/1.1`)) 26 | body <- res.bodyText.compile.string 27 | fis = IO(new FileInputStream(new File("static/html/index.html"))) 28 | file <- readInputStream[IO](fis, 8192, blocker).through(text.utf8Decode).compile.string 29 | } yield assertEquals(body, file) 30 | } 31 | 32 | blocker.test("ws") { blocker => 33 | val req = Request[IO](Method.GET, uri"/ws") 34 | for { 35 | topic <- Topic[IO, DockerData](DockerData.empty) 36 | res <- new Service[IO](blocker, topic).routes.orNotFound.run(req) 37 | body <- res.bodyText.compile.string 38 | } yield assertEquals(body, "This is a WebSocket route.") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/src/main/scala/server/Service.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.File 4 | 5 | import cats.effect.{ Blocker, ContextShift, Sync } 6 | import cats.syntax.all._ 7 | import fs2.{ Pipe, Stream } 8 | import fs2.concurrent.Topic 9 | import io.circe.generic.auto._ 10 | import io.circe.syntax._ 11 | import org.http4s.{ HttpRoutes, StaticFile } 12 | import org.http4s.dsl.Http4sDsl 13 | import org.http4s.server.Router 14 | import org.http4s.server.staticcontent._ 15 | import org.http4s.server.websocket.WebSocketBuilder 16 | import org.http4s.websocket.WebSocketFrame 17 | 18 | import model._ 19 | 20 | class Service[F[_]: Sync: ContextShift](blocker: Blocker, topic: Topic[F, DockerData]) extends Http4sDsl[F] { 21 | 22 | val routes: HttpRoutes[F] = 23 | Router( 24 | "" -> rootRoutes, 25 | "static" -> staticFiles 26 | ) 27 | 28 | private lazy val rootRoutes: HttpRoutes[F] = 29 | HttpRoutes.of[F] { 30 | case request @ GET -> Root => 31 | StaticFile 32 | .fromFile(new File("static/html/index.html"), blocker, Some(request)) 33 | .getOrElseF(NotFound()) 34 | 35 | case GET -> Root / "ws" => 36 | val toClient: Stream[F, WebSocketFrame] = 37 | topic 38 | .subscribe(1) 39 | .map(s => WebSocketFrame.Text(s.asJson.show)) 40 | val fromClient: Pipe[F, WebSocketFrame, Unit] = _.as(()) 41 | WebSocketBuilder[F].build(toClient, fromClient) 42 | } 43 | 44 | private lazy val staticFiles: HttpRoutes[F] = 45 | fileService(FileService.Config("static", blocker)) 46 | } 47 | -------------------------------------------------------------------------------- /static/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Docker Stats Monitor 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
19 | Docker Stats Monitor 20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 | 29 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /server/src/main/scala/server/Stats.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import scala.util.Try 4 | 5 | import cats.{ Applicative, MonadError } 6 | import cats.syntax.all._ 7 | 8 | final case class Stats( 9 | id: String, 10 | name: String, 11 | cpuPercentage: Double, 12 | memUsage: String, 13 | memPercentage: Double, 14 | netIO: String, 15 | blockIO: String, 16 | pids: Int 17 | ) 18 | 19 | object Stats { 20 | def parseCSV[F[_]: MonadError[*[_], Throwable]](line: String): F[Stats] = 21 | for { 22 | parts <- splitLine[F](line) 23 | stats <- parseParts[F](parts) 24 | } yield stats 25 | 26 | private def splitLine[F[_]: MonadError[*[_], Throwable]](line: String): F[Array[String]] = 27 | for { 28 | parts <- Applicative[F].pure(line.split(",,,")) 29 | _ <- MonadError[F, Throwable] 30 | .raiseError(new IllegalStateException(s"Invalid docker stats data csv: $line")) 31 | .whenA(parts.length < 8) 32 | } yield parts 33 | 34 | private def parseParts[F[_]: MonadError[*[_], Throwable]](parts: Array[String]): F[Stats] = 35 | MonadError[F, Throwable].catchNonFatal { 36 | val cpuPercentage = Try(parts(2).replace("%", "").toDouble).toOption.getOrElse(0.0) 37 | val memPercentage = Try(parts(4).replace("%", "").toDouble).toOption.getOrElse(0.0) 38 | Stats( 39 | Try(parts(0).substring(0, 12)).toOption.getOrElse(""), 40 | parts(1), 41 | cpuPercentage, 42 | parts(3), 43 | memPercentage, 44 | parts(5), 45 | parts(6), 46 | Try(parts(7).toInt).toOption.getOrElse(0) 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /server/src/test/scala/server/ProcessesDataStreamSuite.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.{ ByteArrayInputStream, InputStream } 4 | 5 | import cats.effect.{ Blocker, IO, Sync } 6 | import cats.syntax.all._ 7 | import munit.CatsEffectSuite 8 | 9 | class ProcessesDataStreamSuite extends CatsEffectSuite { 10 | 11 | private val lines = List( 12 | "first line contains column names but is discarded regardless", 13 | "", 14 | "8c2f7598fc14,,,cassandra:3.11.6,,,4 hours ago,,,7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp,,,Up 4 hours,,,893kB (virtual 379MB)", 15 | "", 16 | "8d4ae1776df1,,,ubuntu,,,35 seconds ago,,,,,,Up 33 seconds,,,0B (virtual 64.2MB)", 17 | "invalid,,,ubuntu,,,35 seconds ago,,,,,,Up 33 seconds,,,0B (virtual 64.2MB)" 18 | ) 19 | 20 | private val expected = 21 | lines 22 | .filter(_.nonEmpty) 23 | .drop(1) 24 | .traverse(Processes.parseCSV[Either[Throwable, *]]) 25 | .map(_.map(ps => (ps.id -> ps)).toMap) 26 | .toOption 27 | .get 28 | .filter(_._2.id =!= "") 29 | 30 | private def inputStream[F[_]: Sync](s: String): F[InputStream] = 31 | Sync[F].delay(new ByteArrayInputStream(s.getBytes())) 32 | 33 | private val blocker = ResourceFixture(Blocker[IO]) 34 | 35 | blocker.test("processes data stream") { blocker => 36 | ProcessesDataStream 37 | .stream(inputStream[IO](lines.mkString("\n")), blocker) 38 | .compile 39 | .lastOrError 40 | .assertEquals(expected) 41 | } 42 | 43 | blocker.test("invalid data") { blocker => 44 | ProcessesDataStream.stream(inputStream[IO](",\n,"), blocker).compile.drain.attempt.map(_.isLeft).assert 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker stats monitor 2 | 3 | ![Screenshot](./screenshot.png) 4 | 5 | [Http4s](https://http4s.org) application that uses [fs2](https://fs2.io) streams to send `docker stats` and `docker ps` data over a websocket connection to a Scala JS frontend which visualizes the results using the [chartist.js](https://gionkunz.github.io/chartist-js/) library. 6 | 7 | ## Docker image 8 | https://hub.docker.com/r/vasilvasilev97/docker-stats-monitor 9 | 10 | Slim docker image (`FROM scratch`) containing only the application and docker binaries, along with the static site data. The application binary is completely statically compiled and linked Linux binary with absolutely no dependencies, generated using [GraalVM](https://www.graalvm.org) `native-image`. 11 | 12 | Run with: 13 | 14 | ``` 15 | docker run -d --name monitor --memory=64m --memory-swap=64m -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock vasilvasilev97/docker-stats-monitor 16 | ``` 17 | 18 | and visit [localhost:8080](http://localhost:8080/). 19 | 20 | Note that we use `--memory` to configure the amount of memory available to the docker container. GraalVM native images can operate with a reasonably low memory consumption but it's impossible to figure out good ergonomics automatically on a machine with enough resources. Another option would be to configure heap size in the Dockerfile using [native image configuration options](https://github.com/oracle/graal/blob/master/substratevm/OPTIONS.md#garbage-collection-options). 21 | 22 | ## Tests 23 | 24 | There are integration tests with Docker that can be run using `sbt run`. Note: the docker executable needs to be available on the `PATH` and the docker daemon needs to be running when executing the tests. 25 | -------------------------------------------------------------------------------- /server/src/test/scala/server/StatsDataStreamSuite.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import java.io.{ ByteArrayInputStream, InputStream } 4 | 5 | import cats.effect.{ Blocker, IO, Sync } 6 | import cats.syntax.all._ 7 | import munit.CatsEffectSuite 8 | 9 | class StatsDataStreamSuite extends CatsEffectSuite { 10 | 11 | private val lines = List( 12 | "first line contains column names but is discarded regardless", 13 | "", 14 | "977894b9a932,,,optimistic_jang,,,98.00%,,,1.119GiB / 1.944GiB,,,57.57%,,,836B / 0B,,,0B / 0B,,,61", 15 | "", 16 | "74cf0440096b,,,friendly_johnson,,,65.30%,,,1.12GiB / 1.944GiB,,,57.59%,,,836B / 0B,,,0B / 0B,,,61", 17 | "", 18 | "a651eaf37e82,,,sleepy_tereshkova,,,6.01%,,,1.112GiB / 1.944GiB,,,57.20%,,,766B / 0B,,,0B / 0B,,,61", 19 | "invalid,,,sleepy_tereshkova,,,6.01%,,,1.112GiB / 1.944GiB,,,57.20%,,,766B / 0B,,,0B / 0B,,,61" 20 | ) 21 | 22 | private val expected = 23 | lines 24 | .filter(_.nonEmpty) 25 | .drop(1) 26 | .traverse(Stats.parseCSV[Either[Throwable, *]]) 27 | .map(_.map(stats => (stats.id -> stats)).toMap) 28 | .toOption 29 | .get 30 | .filter(_._2.id =!= "") 31 | 32 | private def inputStream[F[_]: Sync](s: String): F[InputStream] = 33 | Sync[F].delay(new ByteArrayInputStream(s.getBytes())) 34 | 35 | private val blocker = ResourceFixture(Blocker[IO]) 36 | 37 | blocker.test("stats data stream") { blocker => 38 | StatsDataStream.stream(inputStream[IO](lines.mkString("\n")), blocker).compile.lastOrError.assertEquals(expected) 39 | } 40 | 41 | blocker.test("invalid data") { blocker => 42 | StatsDataStream.stream(inputStream[IO](",\n,"), blocker).compile.drain.attempt.map(_.isLeft).assert 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/graalvm/graalvm-ce:java11-21.1.0 as builder 2 | 3 | # Install native-image 4 | RUN gu install native-image 5 | 6 | # Install sbt 7 | RUN curl -L https://www.scala-sbt.org/sbt-rpm.repo > /etc/yum.repos.d/sbt-rpm.repo && \ 8 | microdnf install -y sbt 9 | 10 | # Static image requirements 11 | RUN mkdir /staticlibs && \ 12 | curl -L -o musl.tar.gz https://musl.libc.org/releases/musl-1.2.1.tar.gz && \ 13 | mkdir musl && tar -xvzf musl.tar.gz -C musl --strip-components 1 && cd musl && \ 14 | ./configure --disable-shared --prefix=/staticlibs && \ 15 | make && make install && \ 16 | cd / && rm -rf /muscl && rm -f /musl.tar.gz && \ 17 | cp /usr/lib/gcc/x86_64-redhat-linux/8/libstdc++.a /staticlibs/lib/ 18 | 19 | ENV PATH="$PATH:/staticlibs/bin" 20 | ENV CC="musl-gcc" 21 | 22 | RUN curl -L -o zlib.tar.gz https://zlib.net/zlib-1.2.11.tar.gz && \ 23 | mkdir zlib && tar -xvzf zlib.tar.gz -C zlib --strip-components 1 && cd zlib && \ 24 | ./configure --static --prefix=/staticlibs && \ 25 | make && make install && \ 26 | cd / && rm -rf /zlib && rm -f /zlib.tar.gz 27 | 28 | # Copy the build files 29 | COPY . /build 30 | WORKDIR /build 31 | 32 | # Build the native image 33 | RUN sbt clean compile fullOptJS 34 | RUN sbt server/graalvm-native-image:packageBin 35 | RUN rm static/js/client.js.map 36 | RUN curl -L -o docker-latest.tgz http://get.docker.com/builds/Linux/x86_64/docker-latest.tgz 37 | RUN tar -xvzf docker-latest.tgz 38 | 39 | # Copy the native image to an empty container 40 | FROM scratch 41 | COPY --from=builder /build/server/target/graalvm-native-image/server /server 42 | COPY --from=builder /build/static /static 43 | COPY --from=builder /build/docker/docker /docker 44 | ENV PATH "/" 45 | EXPOSE 8080 46 | ENTRYPOINT [ "/server" ] 47 | -------------------------------------------------------------------------------- /server/src/main/scala/server/DockerDataStream.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import scala.concurrent.duration._ 4 | 5 | import cats.Functor 6 | import cats.effect.{ Blocker, ContextShift, Sync, Timer } 7 | import fs2.{ Pipe, Stream } 8 | 9 | import model._ 10 | 11 | object DockerDataStream { 12 | 13 | def stream[F[_]: Sync: ContextShift: Timer](blocker: Blocker): Stream[F, DockerData] = 14 | ticker( 15 | StatsDataStream 16 | .stream[F](DockerStats.input, blocker) 17 | .zip(ProcessesDataStream.stream[F](DockerProcesses.input, blocker)) 18 | .through(combiner) 19 | ) 20 | 21 | private def combiner[F[_]]: Pipe[F, (StatsData, ProcessesData), DockerData] = 22 | _.map { 23 | case (statsData, processesData) => 24 | statsData 25 | .map { 26 | case (id, stats) => 27 | val processes = processesData.get(id).getOrElse(Processes(id, "", "", "", "", "")) 28 | val Stats(_, name, cpuPercentage, memUsage, memPercentage, netIO, blockIO, pids) = stats 29 | val Processes(_, image, created, ports, status, size) = processes 30 | val mappedPorts = if (ports.isEmpty) "No mapped ports" else ports 31 | ContainerData( 32 | id, 33 | name, 34 | image, 35 | created, 36 | status, 37 | cpuPercentage, 38 | memUsage, 39 | memPercentage, 40 | netIO, 41 | blockIO, 42 | pids, 43 | size, 44 | mappedPorts 45 | ) 46 | } 47 | .toSet[ContainerData] 48 | } 49 | 50 | private def ticker[F[_]: Functor: Timer, A](stream: Stream[F, A]): Stream[F, A] = 51 | (Stream.emit(Duration.Zero) ++ Stream.awakeEvery[F](5.seconds)) 52 | .as(stream) 53 | .flatten 54 | } 55 | -------------------------------------------------------------------------------- /server/src/test/scala/server/DockerIntegrationSuite.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import cats.effect.{ Blocker, IO, Resource } 4 | import cats.syntax.all._ 5 | import fs2.io._ 6 | import fs2.text 7 | import munit.CatsEffectSuite 8 | 9 | class DockerIntegrationSuite extends CatsEffectSuite { 10 | 11 | private val dockerRun = new ProcessBuilder() 12 | .command( 13 | "docker", 14 | "run", 15 | "-d", 16 | "--rm", 17 | "-v", 18 | "/var/run/docker.sock:/var/run/docker.sock", 19 | "vasilvasilev97/docker-stats-monitor" 20 | ) 21 | 22 | private def runContainer(blocker: Blocker): IO[String] = { 23 | val fis = IO(dockerRun.start().getInputStream()) 24 | readInputStream(fis, 8192, blocker) 25 | .through(text.utf8Decode) 26 | .compile 27 | .string 28 | .map(_.substring(0, 12)) 29 | } 30 | 31 | private def dockerStop(id: String): ProcessBuilder = 32 | new ProcessBuilder() 33 | .command("docker", "stop", id) 34 | 35 | private def stopContainer(id: String): IO[Unit] = 36 | IO(dockerStop(id).start().waitFor()).void 37 | 38 | private val containerResource: Resource[IO, (Blocker, String)] = 39 | for { 40 | blocker <- Blocker[IO] 41 | id <- Resource.make(runContainer(blocker))(stopContainer) 42 | } yield (blocker, id) 43 | 44 | private val container = ResourceFixture(containerResource) 45 | 46 | container.test("integration") { 47 | case (blocker, id) => 48 | DockerDataStream 49 | .stream[IO](blocker) 50 | .take(3) 51 | .compile 52 | .toList 53 | .map(_.map(_.find(_.id === id).get)) 54 | .map { 55 | _.foreach { data => 56 | assert(data.name.nonEmpty) 57 | assertEquals(data.image, "vasilvasilev97/docker-stats-monitor") 58 | assert(data.runningFor.nonEmpty) 59 | assert(data.status.nonEmpty) 60 | assert(data.cpuPercentage >= 0) 61 | assert(data.memUsage.nonEmpty) 62 | assert(data.memPercentage >= 0) 63 | assert(data.netIO.nonEmpty) 64 | assert(data.blockIO.nonEmpty) 65 | assert(data.pids >= 0) 66 | assert(data.size.nonEmpty) 67 | assert(data.ports.nonEmpty) 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /client/src/test/scala/client/ClientStateSuite.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import munit.FunSuite 4 | 5 | import model._ 6 | 7 | class ClientStateSuite extends FunSuite { 8 | 9 | private val chartState = ChartState( 10 | new TestLineChart[Either[Throwable, *]], 11 | new TestLineChart[Either[Throwable, *]] 12 | ) 13 | 14 | test("add") { 15 | val state = ClientState(Map("974a307336dc" -> chartState)) 16 | val updated = state + ("a50c5dbb6f4a" -> chartState) 17 | assert(updated.map.contains("a50c5dbb6f4a")) 18 | } 19 | 20 | test("remove") { 21 | val state = ClientState(Map("974a307336dc" -> chartState)) 22 | val updated = state - "974a307336dc" 23 | assert(!updated.map.contains("974a307336dc")) 24 | } 25 | 26 | test("get present") { 27 | val state = ClientState(Map("974a307336dc" -> chartState)) 28 | assert(state.get("974a307336dc").isRight) 29 | } 30 | 31 | test("get not present") { 32 | val state = ClientState(Map("974a307336dc" -> chartState)) 33 | assert(state.get("blah").isLeft) 34 | } 35 | 36 | test("partition") { 37 | val stats = Set( 38 | ContainerData( 39 | "974a307336dc", 40 | "xenodochial_colden", 41 | "cassandra:3.11.6", 42 | "3 hours ago", 43 | "Up 3 hours", 44 | 4.59, 45 | "1.147GiB / 1.944GiB", 46 | 59.00, 47 | "1.05kB / 0B", 48 | "0B / 0B", 49 | 42, 50 | "884kB (virtual 379MB)", 51 | "7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp" 52 | ), 53 | ContainerData( 54 | "a50c5dbb6f4a", 55 | "happy_stonebraker", 56 | "cassandra:3.11.6", 57 | "3 hours ago", 58 | "Up 3 hours", 59 | 5.51, 60 | "1.111GiB / 1.944GiB", 61 | 57.14, 62 | "766B / 0B", 63 | "0B / 0B", 64 | 61, 65 | "884kB (virtual 379MB)", 66 | "7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp" 67 | ) 68 | ) 69 | 70 | val state = ClientState[Either[Throwable, *]]( 71 | Map( 72 | "a50c5dbb6f4a" -> chartState, 73 | "8275d414836b" -> chartState 74 | ) 75 | ) 76 | 77 | val ClientState.Partition(removed, added, updated) = state.partition(stats) 78 | 79 | assertEquals(removed, Set("8275d414836b")) 80 | assertEquals(added, Set("974a307336dc")) 81 | assertEquals(updated, Set("a50c5dbb6f4a")) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /client/src/main/scala/client/Client.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import cats.{ Applicative, CommutativeApplicative } 4 | import cats.data.StateT 5 | import cats.effect.Sync 6 | import cats.syntax.all._ 7 | import fs2.Pipe 8 | 9 | import chartist._ 10 | import model._ 11 | 12 | class Client[F[_]: Sync: DOM: Charting] { 13 | 14 | val run: Pipe[F, DockerData, ClientState[F]] = 15 | _.evalMapAccumulate(ClientState.empty[F])(onData).map(_._1) 16 | 17 | implicit private def instance(implicit 18 | A: Applicative[StateT[F, ClientState[F], *]] 19 | ): CommutativeApplicative[StateT[F, ClientState[F], *]] = 20 | new CommutativeApplicative[StateT[F, ClientState[F], *]] { 21 | def ap[A, B]( 22 | ff: StateT[F, ClientState[F], A => B] 23 | )(fa: StateT[F, ClientState[F], A]): StateT[F, ClientState[F], B] = 24 | A.ap(ff)(fa) 25 | 26 | def pure[A](x: A): StateT[F, ClientState[F], A] = 27 | A.pure(x) 28 | } 29 | 30 | private def onData(state: ClientState[F], data: DockerData): F[(ClientState[F], Unit)] = { 31 | val parts = state.partition(data) 32 | val transformation = for { 33 | _ <- parts.removed.unorderedTraverse(onRemoved) 34 | _ <- parts.added.unorderedTraverse(id => onAdded(data.find(_.id === id).get)) 35 | _ <- parts.updated.unorderedTraverse(id => onUpdated(data.find(_.id === id).get)) 36 | } yield () 37 | transformation.run(state) 38 | } 39 | 40 | private def onRemoved(id: String): StateT[F, ClientState[F], Unit] = 41 | StateT(state => DOM[F].onRemoved(id).as(state - id -> ())) 42 | 43 | private def onAdded(cd: ContainerData): StateT[F, ClientState[F], Unit] = 44 | StateT { state => 45 | for { 46 | _ <- DOM[F].onAdded(cd) 47 | cpuChart <- Charting[F].createLineChart( 48 | s"#cpu-${cd.id}", 49 | cd.cpuPercentage, 50 | Options(low = 0) 51 | ) 52 | memChart <- Charting[F].createLineChart( 53 | s"#mem-${cd.id}", 54 | cd.memPercentage, 55 | Options(low = 0) 56 | ) 57 | } yield (state + (cd.id -> ChartState(cpuChart, memChart)), ()) 58 | } 59 | 60 | private def onUpdated(cd: ContainerData): StateT[F, ClientState[F], Unit] = 61 | StateT { state => 62 | for { 63 | cs <- state.get(cd.id) 64 | _ <- cs.cpuChart.update(cd.cpuPercentage) 65 | _ <- cs.memChart.update(cd.memPercentage) 66 | _ <- DOM[F].onUpdated(cd) 67 | } yield (state, ()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /client/src/test/scala/client/ClientSuite.scala: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import cats.effect.IO 4 | import fs2.Stream 5 | import munit.CatsEffectSuite 6 | 7 | import model._ 8 | 9 | class ClientSuite extends CatsEffectSuite { 10 | 11 | implicit private val ioTestDOM = TestDOM[IO] 12 | 13 | implicit private val ioTestCharting = TestCharting[IO] 14 | 15 | private val batch1 = Set( 16 | ContainerData( 17 | "974a307336dc", 18 | "xenodochial_colden", 19 | "cassandra:3.11.6", 20 | "3 hours ago", 21 | "Up 3 hours", 22 | 171.67, 23 | "1.142GiB / 1.944GiB", 24 | 58.73, 25 | "766B / 0B", 26 | "0B / 0B", 27 | 61, 28 | "884kB (virtual 379MB)", 29 | "7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp" 30 | ) 31 | ) 32 | 33 | private val batch2 = Set( 34 | ContainerData( 35 | "974a307336dc", 36 | "xenodochial_colden", 37 | "cassandra:3.11.6", 38 | "3 hours ago", 39 | "Up 3 hours", 40 | 4.59, 41 | "1.147GiB / 1.944GiB", 42 | 59.00, 43 | "1.05kB / 0B", 44 | "0B / 0B", 45 | 42, 46 | "884kB (virtual 379MB)", 47 | "7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp" 48 | ), 49 | ContainerData( 50 | "a50c5dbb6f4a", 51 | "happy_stonebraker", 52 | "cassandra:3.11.6", 53 | "3 hours ago", 54 | "Up 3 hours", 55 | 5.51, 56 | "1.111GiB / 1.944GiB", 57 | 57.14, 58 | "766B / 0B", 59 | "0B / 0B", 60 | 61, 61 | "884kB (virtual 379MB)", 62 | "7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp" 63 | ) 64 | ) 65 | 66 | private val batch3 = Set( 67 | ContainerData( 68 | "a50c5dbb6f4a", 69 | "happy_stonebraker", 70 | "cassandra:3.11.6", 71 | "3 hours ago", 72 | "Up 3 hours", 73 | 5.58, 74 | "1.149GiB / 1.944GiB", 75 | 59.08, 76 | "976B / 0B", 77 | "0B / 0B", 78 | 43, 79 | "884kB (virtual 379MB)", 80 | "7000-7001/tcp, 7199/tcp, 9042/tcp, 9160/tcp" 81 | ) 82 | ) 83 | 84 | private val stream = 85 | Stream(Set.empty[ContainerData], batch1, batch2, batch3, Set.empty[ContainerData]) 86 | 87 | test("client") { 88 | val states = stream 89 | .covary[IO] 90 | .through(new Client[IO].run) 91 | .compile 92 | .toList 93 | .map(_.map(_.map.keySet)) 94 | 95 | val expected = List( 96 | Set.empty[String], 97 | Set("974a307336dc"), 98 | Set("974a307336dc", "a50c5dbb6f4a"), 99 | Set("a50c5dbb6f4a"), 100 | Set.empty[String] 101 | ) 102 | 103 | states.assertEquals(expected) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /client/src/main/scala/client/impl/DOMImpl.scala: -------------------------------------------------------------------------------- 1 | package client.impl 2 | 3 | import cats.effect.Sync 4 | import cats.syntax.all._ 5 | import org.scalajs.dom._ 6 | 7 | import client.DOM 8 | import model._ 9 | 10 | class DOMImpl[F[_]: Sync] extends DOM[F] { 11 | 12 | override def onRemoved(id: String): F[Unit] = 13 | for { 14 | charts <- chartsElement 15 | child <- elementById(s"row-$id") 16 | _ <- removeChild(charts, child) 17 | } yield () 18 | 19 | override def onAdded(cd: ContainerData): F[Unit] = { 20 | val running = cd.status.startsWith("Up") 21 | val card = document.createElement("div") 22 | for { 23 | _ <- Sync[F].delay(card.classList.add("mdc-card")) 24 | nameRow <- rowElement 25 | _ <- List( 26 | nameCardLabelElement(2, "Container name:", cd.name), 27 | nameCardLabelElement(2, "Container id:", cd.id), 28 | nameCardLabelElement(2, "Container image:", cd.image), 29 | nameCardLabelElement(2, "Created:", cd.runningFor, Some(s"running-${cd.id}")), 30 | nameCardLabelElement(2, "Status:", cd.status, Some(s"status-${cd.id}")), 31 | nameCardToggle(cd.id, running) 32 | ).traverse_(_.flatMap(appendChild(nameRow, _))) 33 | chartRow <- chartRowElement(cd.id, running) 34 | _ <- List( 35 | cpuCard(cd), 36 | memCard(cd), 37 | ioCard(cd) 38 | ).traverse_(_.flatMap(appendChild(chartRow, _))) 39 | _ <- appendChild(card, nameRow) 40 | _ <- appendChild(card, chartRow) 41 | div = document.createElement("div") 42 | _ <- Sync[F].delay(div.id = s"row-${cd.id}") 43 | _ <- appendChild(div, card) 44 | _ <- appendChild(div, document.createElement("hr")) 45 | charts <- chartsElement 46 | _ <- appendChild(charts, div) 47 | } yield () 48 | } 49 | 50 | override def onUpdated(cd: ContainerData): F[Unit] = 51 | for { 52 | _ <- updateText(s"running-${cd.id}", cd.runningFor) 53 | _ <- updateText(s"status-${cd.id}", cd.status) 54 | _ <- updateText(s"cpu-usage-${cd.id}", s"${cd.cpuPercentage}%") 55 | _ <- updateText(s"mem-usage-${cd.id}", cd.memUsage) 56 | _ <- updateText(s"net-usage-${cd.id}", cd.netIO) 57 | _ <- updateText(s"block-usage-${cd.id}", cd.blockIO) 58 | _ <- updateText(s"pids-${cd.id}", cd.pids.show) 59 | _ <- updateText(s"size-${cd.id}", cd.size) 60 | } yield () 61 | 62 | private def updateText(id: String, text: String): F[Unit] = 63 | for { 64 | e <- elementById(id) 65 | _ <- Sync[F].delay(e.innerText = text) 66 | } yield () 67 | 68 | private val chartsElement: F[Element] = elementById("charts") 69 | 70 | private def elementById(id: String): F[Element] = 71 | Sync[F].delay(document.getElementById(id)) 72 | 73 | private def appendChild(element: Element, child: Element): F[Unit] = 74 | Sync[F].delay(element.appendChild(child)) >> Sync[F].unit 75 | 76 | private def removeChild(element: Element, child: Element): F[Unit] = 77 | Sync[F].delay(element.removeChild(child)) >> Sync[F].unit 78 | 79 | private val rowElement: F[Element] = 80 | Sync[F].delay { 81 | val row = document.createElement("div") 82 | row.classList.add("mdc-layout-grid__inner") 83 | row 84 | } 85 | 86 | private def chartRowElement(id: String, running: Boolean): F[Element] = 87 | for { 88 | row <- rowElement 89 | _ <- Sync[F].delay(row.id = s"chart-row-$id") 90 | _ <- Sync[F].delay(row.classList.add("hidden")).whenA(!running) 91 | } yield row 92 | 93 | private def cpuCard(cd: ContainerData): F[Element] = 94 | for { 95 | card <- cardElement(5) 96 | _ <- List( 97 | labelElement("CPU usage:"), 98 | textElement(s"${cd.cpuPercentage}%", Some(s"cpu-usage-${cd.id}")), 99 | labelElement("CPU %"), 100 | chartElement(s"cpu-${cd.id}") 101 | ).traverse_(_.flatMap(appendChild(card, _))) 102 | } yield card 103 | 104 | private def memCard(cd: ContainerData): F[Element] = 105 | for { 106 | card <- cardElement(5) 107 | _ <- List( 108 | labelElement("Memory usage:"), 109 | textElement(s"${cd.memUsage}", Some(s"mem-usage-${cd.id}")), 110 | labelElement("Memory %"), 111 | chartElement(s"mem-${cd.id}") 112 | ).traverse_(_.flatMap(appendChild(card, _))) 113 | } yield card 114 | 115 | private def ioCard(cd: ContainerData): F[Element] = 116 | for { 117 | card <- cardElement(2) 118 | _ <- List( 119 | labelElement("Network I/O:"), 120 | textElement(cd.netIO, Some(s"net-usage-${cd.id}")), 121 | labelElement("Block I/O:"), 122 | textElement(cd.blockIO, Some(s"block-usage-${cd.id}")), 123 | labelElement("PIDs:"), 124 | textElement(cd.pids.show, Some(s"pids-${cd.id}")), 125 | labelElement("Size:"), 126 | textElement(cd.size, Some(s"size-${cd.id}")), 127 | labelElement("Ports:"), 128 | textElement(cd.ports, Some(s"ports-${cd.id}")) 129 | ).traverse_(_.flatMap(appendChild(card, _))) 130 | } yield card 131 | 132 | private def cardElement(span: Int): F[Element] = 133 | Sync[F].delay { 134 | val card = document.createElement("div") 135 | card.classList.add(s"mdc-layout-grid__cell--span-$span") 136 | card 137 | } 138 | 139 | private def nameCardLabelElement(span: Int, label: String, text: String, id: Option[String] = None): F[Element] = 140 | for { 141 | div <- Sync[F].delay { 142 | val div = document.createElement("div") 143 | div.classList.add(s"mdc-layout-grid__cell--span-$span") 144 | div 145 | } 146 | _ <- List( 147 | labelElement(label), 148 | textElement(text, id) 149 | ).traverse_(_.flatMap(appendChild(div, _))) 150 | } yield div 151 | 152 | private def nameCardToggle(id: String, running: Boolean): F[Element] = 153 | Sync[F].delay { 154 | val div = document.createElement("div") 155 | div.classList.add("mdc-layout-grid__cell--span-2") 156 | div.classList.add("button-div") 157 | val button = document.createElement("button") 158 | button.setAttribute("type", "button") 159 | button.classList.add("mdc-button") 160 | button.classList.add("mdc-button--raised") 161 | val span = document.createElement("span") 162 | span.classList.add("mdc-button__label") 163 | span.innerText = if (running) DOMImpl.hideStats else DOMImpl.showStats 164 | button.addEventListener( 165 | "click", 166 | (_: Event) => { 167 | val chartRow = document.getElementById(s"chart-row-$id") 168 | if (span.innerText === DOMImpl.showStats) { 169 | chartRow.classList.remove("hidden") 170 | span.innerText = DOMImpl.hideStats 171 | } else { 172 | chartRow.classList.add("hidden") 173 | span.innerText = DOMImpl.showStats 174 | } 175 | } 176 | ) 177 | button.appendChild(span) 178 | div.appendChild(button) 179 | div 180 | } 181 | 182 | private def labelElement(text: String): F[Element] = 183 | Sync[F].delay { 184 | val label = document.createElement("h5") 185 | label.classList.add("label") 186 | label.innerText = text 187 | label 188 | } 189 | 190 | private def textElement(text: String, id: Option[String] = None): F[Element] = 191 | Sync[F].delay { 192 | val p = document.createElement("p") 193 | p.innerText = text 194 | if (id.isDefined) p.id = id.get 195 | p 196 | } 197 | 198 | private def chartElement(id: String): F[Element] = 199 | Sync[F].delay { 200 | val chart = document.createElement("div") 201 | chart.classList.add("ct-chart") 202 | chart.id = id 203 | chart 204 | } 205 | } 206 | 207 | object DOMImpl { 208 | def apply[F[_]: Sync]: DOM[F] = new DOMImpl[F] 209 | 210 | private[impl] val showStats: String = "SHOW STATS" 211 | private[impl] val hideStats: String = "HIDE STATS" 212 | } 213 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------