├── 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 |
23 |
24 |
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 | 
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 |
--------------------------------------------------------------------------------