├── installer ├── templates │ └── new │ │ ├── Procfile │ │ ├── .dockerignore │ │ ├── widgets │ │ ├── time_took │ │ │ ├── time_took.js │ │ │ └── time_took.scss │ │ ├── image │ │ │ ├── image.scss │ │ │ └── image.js │ │ ├── clock │ │ │ ├── clock.scss │ │ │ └── clock.js │ │ ├── text │ │ │ ├── text.js │ │ │ └── text.scss │ │ ├── meter │ │ │ ├── meter.scss │ │ │ └── meter.js │ │ ├── number │ │ │ ├── number.scss │ │ │ └── number.js │ │ ├── list │ │ │ ├── list.scss │ │ │ └── list.js │ │ └── graph │ │ │ ├── graph.scss │ │ │ └── graph.js │ │ ├── elixir_buildpack.config │ │ ├── lib │ │ └── application_name.ex │ │ ├── .babelrc │ │ ├── config │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── config.exs │ │ ├── public │ │ └── assets │ │ │ ├── favicon.ico │ │ │ └── images │ │ │ └── placeholder.png │ │ ├── jobs │ │ ├── random.exs │ │ ├── phrases.exs │ │ ├── buzzwords.exs │ │ ├── convergence.exs │ │ └── stats.exs │ │ ├── assets │ │ ├── javascripts │ │ │ └── application.js │ │ └── stylesheets │ │ │ └── application.scss │ │ ├── Dockerfile │ │ ├── dashboards │ │ ├── rotator.html.eex │ │ ├── layout.html.eex │ │ ├── error.html.eex │ │ ├── sample.html.eex │ │ └── jobs.html.eex │ │ ├── .gitignore │ │ ├── mix.exs │ │ ├── README.md │ │ ├── rel │ │ ├── plugins │ │ │ └── compile_assets_task.exs │ │ └── config.exs │ │ ├── package.json │ │ └── webpack.config.js ├── README.md ├── mix.exs └── lib │ └── kitto_new.ex ├── test ├── fixtures │ ├── views │ │ ├── error.html.eex │ │ ├── layout.html.eex │ │ ├── sample.html.eex │ │ └── folder │ │ │ └── sample.html.eex │ └── jobs │ │ ├── invalid_job.exs │ │ ├── valid_job.exs │ │ └── updated_valid_job.file ├── generator.exs ├── mix │ └── tasks │ │ ├── generators │ │ ├── kitto.gen.job_test.ex │ │ ├── kitto.gen.dashboard_test.exs │ │ └── kitto.gen.widget_test.exs │ │ ├── kitto.new_test.exs │ │ └── kitto.install_test.exs ├── job │ ├── validator_test.exs │ └── dsl_test.exs ├── test_helper.exs ├── support │ ├── mix_generator_helper.exs │ └── file_assertion_helper.exs ├── view_test.exs ├── time_test.exs ├── kitto_test.exs ├── backoff_server_test.exs ├── plugs │ └── authentication_test.exs ├── job_test.exs ├── runner_test.exs ├── code_reloader_test.exs └── stats_server_test.exs ├── .gitignore ├── lib ├── mix │ └── tasks │ │ ├── generators │ │ ├── templates │ │ │ ├── job.exs.eex │ │ │ ├── dashboard.html.eex │ │ │ └── widget │ │ │ │ ├── widget.js.eex │ │ │ │ └── widget.scss.eex │ │ ├── kitto.gen.job.ex │ │ ├── kitto.gen.dashboard.ex │ │ └── kitto.gen.widget.ex │ │ ├── kitto.server.ex │ │ └── kitto.install.ex ├── kitto │ ├── backoff.ex │ ├── job │ │ ├── workspace.ex │ │ ├── error.ex │ │ ├── validator.ex │ │ └── dsl.ex │ ├── generator.ex │ ├── runner │ │ └── job_supervisor.ex │ ├── time.ex │ ├── job.ex │ ├── plugs │ │ └── authentication.ex │ ├── view.ex │ ├── code_reloader.ex │ ├── backoff_server.ex │ ├── stats_server.ex │ ├── notifier.ex │ ├── runner.ex │ └── router.ex └── kitto.ex ├── config ├── test.exs └── config.exs ├── .travis.yml ├── package.json ├── ISSUE_TEMPLATE.md ├── .ebert.yml ├── LICENSE.txt ├── priv └── static │ ├── helpers.js │ ├── widget.js │ └── kitto.js ├── mix.exs ├── CONTRIBUTING.md ├── Dockerfile ├── CODE_OF_CONDUCT.md ├── mix.lock ├── .credo.exs ├── CHANGELOG.md └── README.md /installer/templates/new/Procfile: -------------------------------------------------------------------------------- 1 | web: mix kitto.server 2 | -------------------------------------------------------------------------------- /installer/templates/new/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | deps 3 | _build 4 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/time_took/time_took.js: -------------------------------------------------------------------------------- 1 | import './time_took.scss'; 2 | -------------------------------------------------------------------------------- /test/fixtures/views/error.html.eex: -------------------------------------------------------------------------------- 1 |
<%= code %> - <%= message %>
2 | -------------------------------------------------------------------------------- /installer/templates/new/elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | erlang_version=19.3 2 | elixir_version=1.3.0 3 | -------------------------------------------------------------------------------- /installer/templates/new/lib/application_name.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= application_module %> do 2 | end 3 | -------------------------------------------------------------------------------- /test/fixtures/views/layout.html.eex: -------------------------------------------------------------------------------- 1 |
<%= @template %>
2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | erl_crash.dump 6 | *.ez 7 | /installer/_build 8 | -------------------------------------------------------------------------------- /installer/templates/new/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /installer/templates/new/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :kitto, reload_code?: true, watch_assets?: true 4 | -------------------------------------------------------------------------------- /test/generator.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.GeneratorTest do 2 | use ExUnit.Case, async: true 3 | doctest Kitto.Generator 4 | end 5 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/image/image.scss: -------------------------------------------------------------------------------- 1 | .widget-image { 2 | background-position: center; 3 | background-size: cover; 4 | } 5 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/time_took/time_took.scss: -------------------------------------------------------------------------------- 1 | .widget-list { 2 | &.time-took { 3 | background-color: #9c4274; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /lib/mix/tasks/generators/templates/job.exs.eex: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | job :<%= name %>, every: {5, :seconds} do 4 | broadcast! %{} 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/jobs/invalid_job.exs: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | job :invalid, every: :minute do 4 | (str "this doesn't seem" "like Elixir") 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/jobs/valid_job.exs: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | job :valid, every: :second do 4 | broadcast! :text, %{text: "Hello from Kitto"} 5 | end 6 | -------------------------------------------------------------------------------- /installer/templates/new/public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kittoframework/kitto/HEAD/installer/templates/new/public/assets/favicon.ico -------------------------------------------------------------------------------- /installer/templates/new/jobs/random.exs: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | job :random, every: :second do 4 | broadcast! %{value: :rand.uniform * 100 |> Float.round} 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/jobs/updated_valid_job.file: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | job :updated_valid, every: :second do 4 | broadcast! :text, %{text: "Hello from Kitto"} 5 | end 6 | -------------------------------------------------------------------------------- /installer/templates/new/public/assets/images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kittoframework/kitto/HEAD/installer/templates/new/public/assets/images/placeholder.png -------------------------------------------------------------------------------- /test/fixtures/views/sample.html.eex: -------------------------------------------------------------------------------- 1 |

Hello from <%= Kitto.Mixfile.project[:app] %>

2 | <%= if assigns[:user] do %> 3 |

Hello from <%= @user %>

4 | <% end %> 5 | -------------------------------------------------------------------------------- /test/fixtures/views/folder/sample.html.eex: -------------------------------------------------------------------------------- 1 |

Hello from <%= Kitto.Mixfile.project[:app] %>

2 | <%= if assigns[:user] do %> 3 |

Hello from <%= @user %>

4 | <% end %> 5 | -------------------------------------------------------------------------------- /installer/README.md: -------------------------------------------------------------------------------- 1 | ## mix kitto.new 2 | 3 | Provides `kitto.new` installer as an archive. To build and install it locally: 4 | 5 | $ cd installer 6 | $ MIX_ENV=prod mix archive.build 7 | $ mix archive.install 8 | -------------------------------------------------------------------------------- /installer/templates/new/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | import '../stylesheets/application.scss'; 2 | 3 | import $ from 'jquery'; 4 | import {Kitto} from 'kitto'; 5 | 6 | window.jQuery = window.$ = $; 7 | 8 | Kitto.start(); 9 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :kitto, root: Path.dirname(__DIR__) 4 | config :kitto, templates_dir: "test/fixtures/views" 5 | config :kitto, default_layout: "layout" 6 | config :kitto, otp_app: :kitto 7 | 8 | config :logger, level: :warn 9 | -------------------------------------------------------------------------------- /installer/templates/new/jobs/phrases.exs: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | job :phrases, every: {4, :seconds} do 4 | phrases = ["This is your shiny new dashboard", "Built on the Kitto Framework"] 5 | 6 | broadcast! %{text: (phrases |> Enum.shuffle |> Enum.take(1))} 7 | end 8 | -------------------------------------------------------------------------------- /lib/kitto/backoff.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Backoff do 2 | @moduledoc """ 3 | Specification for a backoff module to be used with Kitto. 4 | """ 5 | 6 | @callback succeed(atom) :: any 7 | @callback fail(atom):: any 8 | @callback backoff!(atom) :: any 9 | end 10 | -------------------------------------------------------------------------------- /lib/kitto/job/workspace.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Job.Workspace do 2 | @moduledoc false 3 | 4 | @spec load_file(String.t(), pid()) :: {any(), any()} 5 | def load_file(file, server) do 6 | Code.eval_string(File.read!(file), [runner_server: server], file: file, line: 1) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /installer/templates/new/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM zorbash/kitto 2 | 3 | ENV MIX_ENV prod 4 | 5 | RUN mkdir /dashboard 6 | WORKDIR /dashboard 7 | 8 | ADD ./mix.exs ./ 9 | ADD ./mix.lock ./ 10 | RUN mix deps.get 11 | 12 | ADD ./package.json ./ 13 | RUN npm install --silent 14 | 15 | ADD . /dashboard 16 | RUN npm run build 17 | RUN mix compile 18 | 19 | CMD mix kitto.server 20 | -------------------------------------------------------------------------------- /installer/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.New.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :kitto_new, 6 | version: "0.9.2", 7 | elixir: "~> 1.4"] 8 | end 9 | 10 | # Configuration for the OTP application 11 | # 12 | # Type `mix help compile.app` for more information 13 | def application do 14 | [applications: []] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /installer/templates/new/dashboards/rotator.html.eex: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | <%= for dashboard <- @dashboards do %> 8 | <%= Kitto.View.render_template(dashboard) %> 9 | <% end %> 10 |
11 | -------------------------------------------------------------------------------- /installer/templates/new/jobs/buzzwords.exs: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | job :buzzwords, every: :second do 4 | random = fn -> :rand.uniform * 100 |> Float.round end 5 | 6 | list = ~w[synergy startup catalyst docker microservice container elixir react] 7 | |> Enum.map(fn (w) -> %{ label: w, value: random.() } end) 8 | |> Enum.shuffle 9 | 10 | broadcast! %{items: list} 11 | end 12 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :kitto, root: System.cwd 6 | config :kitto, templates_dir: "dashboards" 7 | config :kitto, default_layout: "layout" 8 | config :kitto, default_dashboard: "sample" 9 | 10 | if File.exists?(Path.join("config", "#{Mix.env}.exs")) do 11 | import_config "#{Mix.env}.exs" 12 | end 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | matrix: 4 | include: 5 | - otp_release: 18.3 6 | elixir: 1.6.1 7 | - otp_release: 19.2 8 | elixir: 1.5.1 9 | - otp_release: 20.0 10 | elixir: 1.6.6 11 | - otp_release: 20.0 12 | elixir: 1.7.4 13 | 14 | script: 15 | - "MIX_ENV=test mix do deps.get, test && mix credo && mix coveralls.travis" 16 | after_script: 17 | - mix deps.get --only docs 18 | - MIX_ENV=docs mix inch.report 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kitto", 3 | "version": "0.5.0", 4 | "description": "Kitto core library", 5 | "license": "MIT", 6 | "main": "./priv/static/kitto.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/kittoframework/kitto.git" 10 | }, 11 | "author": "Dimitris Zorbas ", 12 | "files": [ 13 | "README.md", 14 | "LICENSE.md", 15 | "package.json", 16 | "priv" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /installer/templates/new/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :kitto, reload_code?: false, watch_assets?: false, serve_assets?: true 4 | 5 | # For distillery releases 6 | # Read more: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-Distillery 7 | # config :kitto, root: :otp_app 8 | 9 | # For heroku deployments 10 | # Read more: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-Heroku 11 | # config :kitto, assets_path: "priv/static" 12 | -------------------------------------------------------------------------------- /lib/mix/tasks/generators/templates/dashboard.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 12 |
13 | -------------------------------------------------------------------------------- /lib/mix/tasks/generators/templates/widget/widget.js.eex: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Widget from '../../assets/javascripts/widget'; 3 | 4 | import './<%= name %>.scss'; 5 | 6 | Widget.mount(class <%= class %> extends Widget { 7 | render() { 8 | return ( 9 |
10 |
{this.props.title} 11 | 12 |

{this.props.moreinfo}

13 |
14 | ); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | For feature requests, please be as descriptive as possible, providing a 2 | real use case and context of how you imagine the feature to work. 3 | 4 | For bug reports please fill the following info: 5 | 6 | ### Environment 7 | 8 | * Kitto version: 9 | * Elixir / Hex version (mix hex.info): 10 | * Node / NPM version (node -v) (npm -v): 11 | * Operating system: 12 | 13 | ### Current behavior 14 | 15 | Include code samples, errors, stacktraces and screenshots if appropriate. 16 | 17 | ### Expected behavior 18 | -------------------------------------------------------------------------------- /lib/kitto/job/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Job.Error do 2 | defexception [:message] 3 | 4 | def exception(%{exception: e, job: job}) do 5 | import Exception 6 | 7 | file = Path.relative_to(job.definition.file, Kitto.root) 8 | message = """ 9 | Job :#{job.name} failed to run. 10 | Defined in: #{format_file_line(file, job.definition.line)} 11 | Error: #{format_banner(:error, e)} 12 | Stacktrace: #{format_stacktrace(System.stacktrace)} 13 | """ 14 | 15 | %Kitto.Job.Error{message: message} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/kitto/job/validator.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Job.Validator do 2 | @moduledoc """ 3 | Performs basic validations on job files. 4 | """ 5 | 6 | @doc """ 7 | Returns true if the file specified contains no syntax errors, 8 | false otherwise 9 | """ 10 | def valid?(file), do: file |> File.read! |> syntax_valid? 11 | 12 | defp syntax_valid?(str) when is_bitstring(str) do 13 | str |> Code.string_to_quoted |> syntax_valid? 14 | end 15 | defp syntax_valid?({:ok, _}), do: true 16 | defp syntax_valid?({:error, _}), do: false 17 | end 18 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/image/image.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Widget} from 'kitto'; 3 | 4 | import './image.scss'; 5 | 6 | const placeholder = '/assets/images/placeholder.png'; 7 | 8 | class Image extends Widget { 9 | image() { 10 | return { 11 | backgroundImage: `url(${this.state.image || placeholder})` 12 | }; 13 | } 14 | render() { 15 | return ( 16 |
17 | ); 18 | } 19 | }; 20 | 21 | Widget.mount(Image); 22 | export default Image; 23 | -------------------------------------------------------------------------------- /installer/templates/new/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | 7 | # Numerous always-ignore extensions 8 | *.diff 9 | *.err 10 | *.orig 11 | *.log 12 | *.rej 13 | *.swo 14 | *.swp 15 | *.vi 16 | *~ 17 | *.sass-cache 18 | 19 | # OS or Editor files/dirs 20 | .DS_Store 21 | .cache 22 | .project 23 | .settings 24 | .tmproj 25 | nbproject 26 | Thumbs.db 27 | 28 | # NPM packages 29 | node_modules/ 30 | 31 | # Compiled Assets 32 | public/* 33 | !public/assets 34 | public/assets/* 35 | !public/assets/favicon.* 36 | !public/assets/images 37 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/clock/clock.scss: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Sass declarations 3 | // ---------------------------------------------------------------------------- 4 | $background-color: #dc5945; 5 | 6 | // ---------------------------------------------------------------------------- 7 | // Widget-clock styles 8 | // ---------------------------------------------------------------------------- 9 | .widget-clock { 10 | background-color: $background-color; 11 | 12 | .time { 13 | font-size: 55px; 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /test/mix/tasks/generators/kitto.gen.job_test.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.Gen.JobTest do 2 | use ExUnit.Case, async: false 3 | import Kitto.FileAssertionHelper 4 | 5 | setup do 6 | on_exit fn -> 7 | File.rm_rf! Path.join(tmp_path, "my_widget") 8 | end 9 | Mix.Task.clear 10 | :ok 11 | end 12 | 13 | test "fails when job name not provided" do 14 | assert_raise Mix.Error, "No job name provided", fn -> 15 | Mix.Tasks.Kitto.Gen.Job.run([]) 16 | end 17 | end 18 | 19 | test "creates job" do 20 | Mix.Tasks.Kitto.Gen.Job.run(["--path", tmp_path, "my_job"]) 21 | assert_file Path.join(tmp_path, "my_job.exs") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /installer/templates/new/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule <%= application_module %>.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :<%= application_name %>, 6 | version: "0.0.1", 7 | elixir: "~> 1.3", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | deps: deps()] 11 | end 12 | 13 | # Configuration for the OTP application. 14 | # 15 | # Type `mix help compile.app` for more information. 16 | def application do 17 | [applications: [:logger, :kitto]] 18 | end 19 | 20 | # Specifies your project dependencies. 21 | # 22 | # Type `mix help deps` for examples and options. 23 | defp deps do 24 | [<%= kitto_dep %>] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /installer/templates/new/README.md: -------------------------------------------------------------------------------- 1 | # <%= application_module %> 2 | 3 | To start your Dashboard: 4 | 5 | * Install dependencies with `mix deps.get` 6 | * Install Node.js dependencies with `npm install` 7 | * Start a development server with `mix kitto.server` 8 | 9 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 10 | 11 | Ready to run in production? Please [check our deployment guides](https://github.com/kittoframework/kitto#deployment). 12 | 13 | ## Learn more 14 | 15 | * Official website: http://kitto.io 16 | * Guides: https://github.com/kittoframework/kitto/wiki 17 | * Docs: https://hexdocs.pm/kitto/api-reference.html 18 | * Source: https://github.com/kittoframework/kitto 19 | -------------------------------------------------------------------------------- /test/job/validator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Job.ValidatorTest do 2 | use ExUnit.Case, async: true 3 | 4 | @jobs_dir Path.join ~w(test fixtures jobs) 5 | 6 | setup do 7 | %{ 8 | valid_job: Path.join(@jobs_dir, "valid_job.exs"), 9 | invalid_job: Path.join(@jobs_dir, "invalid_job.exs") 10 | } 11 | end 12 | 13 | test """ 14 | #valid? returns true when the given file does not contain syntax errors 15 | """, %{valid_job: job} do 16 | assert Kitto.Job.Validator.valid?(job) == true 17 | end 18 | 19 | test """ 20 | #valid? returns false when the given file contains syntax errors 21 | """, %{invalid_job: job} do 22 | assert Kitto.Job.Validator.valid?(job) == false 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/kitto/generator.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Generator do 2 | @moduledoc """ 3 | Convenience when building generators for Kitto 4 | """ 5 | 6 | @doc ~S""" 7 | Parses arguments passed from command line. 8 | 9 | Examples: 10 | 11 | iex> Kitto.Generator.parse_options(["my_widget"]) 12 | {[], ["my_widget"], []} 13 | 14 | iex> Kitto.Generator.parse_options(["-p", "dashboard_path", "my_dashboard"]) 15 | {[path: "dashboard_path"], ["my_dashboard"], []} 16 | 17 | iex> Kitto.Generator.parse_options(["--path", "dash", "my_dashboard"]) 18 | {[path: "dash"], ["my_dashboard"], []} 19 | """ 20 | def parse_options(argv) do 21 | OptionParser.parse(argv, aliases: [p: :path]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/mix/tasks/generators/kitto.gen.dashboard_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.Gen.DashboardTest do 2 | use ExUnit.Case, async: false 3 | import Kitto.FileAssertionHelper 4 | 5 | setup do 6 | on_exit fn -> 7 | File.rm_rf! Path.join(tmp_path(), "my_dash.html.eex") 8 | end 9 | Mix.Task.clear 10 | :ok 11 | end 12 | 13 | test "fails when dashboard name not provided" do 14 | assert_raise Mix.Error, "No dashboard name provided", fn -> 15 | Mix.Tasks.Kitto.Gen.Dashboard.run([]) 16 | end 17 | end 18 | 19 | test "creates dashboard" do 20 | Mix.Tasks.Kitto.Gen.Dashboard.run(["--path", tmp_path(), "my_dash"]) 21 | assert_file Path.join(tmp_path(), "my_dash.html.eex") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/mix/tasks/generators/templates/widget/widget.scss.eex: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Sass declarations 3 | // ---------------------------------------------------------------------------- 4 | $background-color: #ec663c; 5 | 6 | $title-color: rgba(255, 255, 255, 0.7); 7 | $moreinfo-color: rgba(255, 255, 255, 0.7); 8 | 9 | // ---------------------------------------------------------------------------- 10 | // Widget-text styles 11 | // ---------------------------------------------------------------------------- 12 | .widget-<%= name %> { 13 | 14 | background-color: $background-color; 15 | 16 | .title { 17 | color: $title-color; 18 | } 19 | 20 | .more-info { 21 | color: $moreinfo-color; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.ebert.yml: -------------------------------------------------------------------------------- 1 | # This configuration was used Ebert to review the kittoframework/kitto repository 2 | # on 0d690330dfb1d4fc4c03f007dddfbaaaeead1efa. 3 | # You can make this the default configuration for future reviews by moving this 4 | # file to your repository as `.ebert.yml` and pushing it to GitHub, and tweak 5 | # it as you wish - To know more on how to change this file to better review your 6 | # repository you can go to https://ebertapp.io/docs/config and see the configuration 7 | # details. 8 | --- 9 | styleguide: plataformatec/linters 10 | engines: 11 | credo: 12 | enabled: true 13 | fixme: 14 | enabled: true 15 | eslint: 16 | enabled: false 17 | scss-lint: 18 | enabled: true 19 | remark-lint: 20 | enabled: true 21 | exclude_paths: 22 | - config 23 | - test 24 | 25 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/text/text.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Widget} from 'kitto'; 3 | 4 | import './text.scss'; 5 | 6 | class Text extends Widget { 7 | status() { 8 | if (!this.state.status) { return ""; } 9 | 10 | return`status-${this.state.status}`; 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 |

{this.state.title || this.props.title}

17 |

{this.state.text || this.props.text}

18 |

{this.state.moreinfo || this.props.moreinfo}

19 |

{this.updatedAt(this.state.updated_at)}

20 |
21 | ); 22 | } 23 | }; 24 | 25 | Widget.mount(Text); 26 | export default Text; 27 | -------------------------------------------------------------------------------- /installer/templates/new/jobs/convergence.exs: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | defmodule Kitto.Jobs.Convergence do 4 | def new, do: Agent.start(fn -> 0 end) 5 | 6 | def points(pid, n \\ 10), do: points(pid, n, [point(pid)]) 7 | defp points(_, n, acc) when length(acc) == n, do: acc 8 | defp points(pid, n, acc), do: points(pid, n, acc ++ [point(pid)]) 9 | defp point(pid), do: %{x: pid |> next_point, y: random()} 10 | 11 | defp next_point(pid) do 12 | pid |> Agent.get_and_update(fn(n) -> next = n + 1; {next, next} end) 13 | end 14 | 15 | defp random, do: :rand.uniform * 100 |> Float.round 16 | end 17 | 18 | {:ok, convergence} = Kitto.Jobs.Convergence.new 19 | points = &(&1 |> Kitto.Jobs.Convergence.points) 20 | 21 | job :convergence, every: {2, :seconds} do 22 | broadcast! %{points: convergence |> points.()} 23 | end 24 | -------------------------------------------------------------------------------- /installer/templates/new/rel/plugins/compile_assets_task.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.CompileAssetsTask do 2 | use Mix.Releases.Plugin 3 | 4 | def before_assembly(_) do 5 | info "[CompileAssetsTask] Compiling assets" 6 | 7 | case System.cmd("npm", ["run", "build"]) do 8 | {output, 0} -> 9 | info output 10 | nil 11 | {output, error_code} -> {:error, output, error_code} 12 | end 13 | end 14 | 15 | def before_assembly(_, _), do: before_assembly(nil) 16 | def after_assembly(%Release{} = _release), do: nil 17 | def after_assembly(_, _), do: nil 18 | def before_package(%Release{} = _release), do: nil 19 | def before_package(_, _), do: nil 20 | def after_package(%Release{} = _release), do: nil 21 | def after_package(_, _), do: nil 22 | def after_cleanup(%Release{} = _release), do: nil 23 | end 24 | -------------------------------------------------------------------------------- /test/mix/tasks/generators/kitto.gen.widget_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.Gen.WidgetTest do 2 | use ExUnit.Case, async: false 3 | import Kitto.FileAssertionHelper 4 | 5 | setup do 6 | runner = fn -> 7 | Mix.Tasks.Kitto.Gen.Widget.run(["--path", tmp_path(), "my_widget"]) 8 | end 9 | on_exit fn -> 10 | File.rm_rf! Path.join(tmp_path(), "my_widget") 11 | end 12 | Mix.Task.clear 13 | {:ok, [runner: runner]} 14 | end 15 | 16 | test "fails when widget name not provided" do 17 | assert_raise Mix.Error, "No widget name provided", fn -> 18 | Mix.Tasks.Kitto.Gen.Widget.run([]) 19 | end 20 | end 21 | 22 | test "creates widget", %{runner: runner} do 23 | runner.() 24 | assert_file Path.join(tmp_path(), "my_widget/my_widget.js") 25 | assert_file Path.join(tmp_path(), "my_widget/my_widget.scss") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /installer/templates/new/dashboards/layout.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dashboard 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | <%= @template %> 19 |
20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /lib/kitto/runner/job_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Runner.JobSupervisor do 2 | @moduledoc """ 3 | Module responsible of job processes supervision 4 | """ 5 | 6 | use Supervisor 7 | 8 | @max_restarts Application.get_env :kitto, :job_max_restarts, 300 9 | 10 | @doc """ 11 | Starts the job supervision tree 12 | """ 13 | def start_link(opts) do 14 | Supervisor.start_link(__MODULE__, opts, name: opts[:name]) 15 | end 16 | 17 | @doc false 18 | def init(opts) do 19 | children = opts.jobs |> Enum.map(&child_spec/1) 20 | 21 | supervise(children, strategy: :one_for_one, max_restarts: @max_restarts) 22 | end 23 | 24 | @doc """ 25 | Dynamically attaches a child process for the given job 26 | """ 27 | def start_job(pid, job) do 28 | Supervisor.start_child(pid, child_spec(job)) 29 | end 30 | 31 | defp child_spec(job) do 32 | worker(Kitto.Job, [job], id: job.name) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/mix/tasks/generators/kitto.gen.job.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.Gen.Job do 2 | use Mix.Task 3 | import Mix.Generator 4 | import Kitto.Generator 5 | 6 | @shortdoc "Generates a new job" 7 | 8 | @template Path.join Path.expand("./templates", __DIR__), "job.exs.eex" 9 | 10 | @moduledoc """ 11 | Generates a new empty job 12 | 13 | Usage: 14 | 15 | $ mix kitto.gen.job some_job 16 | # generates `jobs/some_job.exs` 17 | """ 18 | 19 | @doc false 20 | def run(argv) do 21 | {opts, args, _} = parse_options(argv) 22 | case List.first(args) do 23 | nil -> 24 | Mix.shell.error """ 25 | Usage: 26 | 27 | mix kitto.gen.job text 28 | """ 29 | Mix.raise "No job name provided" 30 | job -> 31 | path = Path.join(opts[:path] || "jobs", "#{job}.exs") 32 | create_file path, EEx.eval_file(@template, [name: job]) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/text/text.scss: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Sass declarations 3 | // ---------------------------------------------------------------------------- 4 | $background-color: #ec663c; 5 | 6 | $title-color: rgba(255, 255, 255, 0.7); 7 | $moreinfo-color: rgba(255, 255, 255, 0.7); 8 | $updated-at-color: rgba(0, 0, 0, 0.3); 9 | 10 | // ---------------------------------------------------------------------------- 11 | // Widget-text styles 12 | // ---------------------------------------------------------------------------- 13 | .widget-text { 14 | 15 | background-color: $background-color; 16 | 17 | .title { 18 | color: $title-color; 19 | } 20 | 21 | .more-info { 22 | color: $moreinfo-color; 23 | } 24 | 25 | .updated-at { 26 | color: $updated-at-color; 27 | } 28 | 29 | & h3 { 30 | font-size: 25px; 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/meter/meter.scss: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Sass declarations 3 | // ---------------------------------------------------------------------------- 4 | $background-color: #9c4274; 5 | 6 | $title-color: rgba(255, 255, 255, 0.7); 7 | $moreinfo-color: rgba(255, 255, 255, 0.3); 8 | 9 | $meter-background: darken($background-color, 15%); 10 | 11 | // ---------------------------------------------------------------------------- 12 | // Widget-meter styles 13 | // ---------------------------------------------------------------------------- 14 | .widget-meter { 15 | 16 | background-color: $background-color; 17 | 18 | input.meter { 19 | background-color: $meter-background; 20 | color: #fff; 21 | } 22 | 23 | .title { 24 | color: $title-color; 25 | } 26 | 27 | .more-info { 28 | color: $moreinfo-color; 29 | } 30 | 31 | .updated-at { 32 | color: rgba(0, 0, 0, 0.3); 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /lib/mix/tasks/generators/kitto.gen.dashboard.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.Gen.Dashboard do 2 | use Mix.Task 3 | import Mix.Generator 4 | import Kitto.Generator 5 | 6 | @shortdoc "Generates a new empty dashboard template" 7 | 8 | @template Path.join Path.expand("./templates", __DIR__), "dashboard.html.eex" 9 | 10 | @moduledoc """ 11 | Generates a new empty dashboard template 12 | 13 | Usage: 14 | 15 | $ mix kitto.gen.dashboard all_the_data 16 | # generates `dashboards/all_the_data.html.eex` 17 | """ 18 | 19 | @doc false 20 | def run(argv) do 21 | {opts, args, _} = parse_options(argv) 22 | case List.first(args) do 23 | nil -> 24 | Mix.shell.error """ 25 | Usage: 26 | 27 | mix kitto.gen.dashboard sample 28 | """ 29 | Mix.raise "No dashboard name provided" 30 | dashboard -> 31 | path = Path.join(opts[:path] || "dashboards", "#{dashboard}.html.eex") 32 | create_file path, File.read!(@template) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/number/number.scss: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Sass declarations 3 | // ---------------------------------------------------------------------------- 4 | $background-color: #47bbb3; 5 | $value-color: #fff; 6 | 7 | $title-color: rgba(255, 255, 255, 0.7); 8 | $moreinfo-color: rgba(255, 255, 255, 0.7); 9 | 10 | // ---------------------------------------------------------------------------- 11 | // Widget-number styles 12 | // ---------------------------------------------------------------------------- 13 | .widget-number { 14 | 15 | background-color: $background-color; 16 | 17 | .title { 18 | color: $title-color; 19 | } 20 | 21 | .value { 22 | color: $value-color; 23 | } 24 | 25 | .change-rate { 26 | font-weight: 500; 27 | font-size: 30px; 28 | color: $value-color; 29 | } 30 | 31 | .more-info { 32 | color: $moreinfo-color; 33 | } 34 | 35 | .updated-at { 36 | color: rgba(0, 0, 0, 0.3); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/clock/clock.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Widget} from 'kitto'; 3 | 4 | import './clock.scss'; 5 | 6 | class Clock extends Widget { 7 | constructor(props) { 8 | super(props); 9 | this.state = Clock.dateTime() 10 | setInterval(this.update.bind(this), 500); 11 | } 12 | update() { this.setState(Clock.dateTime()); } 13 | render() { 14 | return ( 15 |
16 |

{this.state.date}

17 |

{this.state.time}

18 |
19 | ); 20 | } 21 | static formatTime(i) { return i < 10 ? "0" + i : i; } 22 | static dateTime() { 23 | var today = new Date(), 24 | h = today.getHours(), 25 | m = today.getMinutes(), 26 | s = today.getSeconds(), 27 | m = Clock.formatTime(m), 28 | s = Clock.formatTime(s); 29 | 30 | return { 31 | time: (h + ":" + m + ":" + s), 32 | date: today.toDateString(), 33 | } 34 | } 35 | }; 36 | 37 | Widget.mount(Clock); 38 | export default Clock; 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Dimitris Zorbas 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.TestHelper do 2 | def atomify_map(map) do 3 | for {key, value} <- map, into: %{}, do: {String.to_atom(key), value} 4 | end 5 | 6 | def wait_for(name, interval \\ 100, timeout \\ 1000) do 7 | pid = self() 8 | spawn_link(fn -> await_process(pid, name, interval) end) 9 | 10 | receive do 11 | {:started, awaited} -> awaited 12 | after 13 | timeout -> exit({:wait_failed, "could not start process: #{name}"}) 14 | end 15 | end 16 | 17 | defp await_process(pid, name, interval) do 18 | receive do 19 | after 20 | interval -> 21 | awaited = Process.whereis(name) 22 | 23 | if awaited && Process.alive?(awaited) do 24 | send pid, {:started, awaited} 25 | exit(:normal) 26 | else 27 | await_process(pid, name, interval) 28 | end 29 | end 30 | end 31 | end 32 | 33 | Code.require_file(Path.join("support", "file_assertion_helper.exs"), __DIR__) 34 | Code.require_file(Path.join("support", "mix_generator_helper.exs"), __DIR__) 35 | 36 | Mix.shell(Mix.Shell.Process) 37 | ExUnit.configure(exclude: [pending: true]) 38 | ExUnit.start() 39 | -------------------------------------------------------------------------------- /lib/kitto/time.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Time do 2 | @moduledoc """ 3 | This module defines functions to handle time conversions. 4 | """ 5 | 6 | @doc """ 7 | Return the number of milliseconds for the given arguments. 8 | 9 | When a tuple is passed the first element is interpreted as the number to be converted 10 | in milliseconds and the second element as the time unit to convert from. 11 | 12 | An atom can also be used (one of `[:second, :minute, :hour, :day]`) for convenience. 13 | """ 14 | @spec mseconds(tuple() | atom()) :: nil | non_neg_integer() 15 | def mseconds({n, :milliseconds}), do: n 16 | 17 | def mseconds({1, duration}) when duration in [:second, :minute, :hour, :day] do 18 | apply __MODULE__, :mseconds, [duration] 19 | end 20 | 21 | def mseconds({n, duration}) when duration in [:seconds, :minutes, :hours] do 22 | apply :timer, duration, [n] 23 | end 24 | 25 | def mseconds({n, :days}), do: n * mseconds({24, :hours}) 26 | 27 | def mseconds(nil), do: nil 28 | def mseconds(:second), do: mseconds({1, :seconds}) 29 | def mseconds(:minute), do: mseconds({1, :minutes}) 30 | def mseconds(:hour), do: mseconds({1, :hours}) 31 | def mseconds(:day), do: mseconds({24, :hours}) 32 | end 33 | -------------------------------------------------------------------------------- /installer/templates/new/dashboards/error.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= code %> - <%= message %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 36 | 37 | 38 | 39 |
40 |

<%= code %>

41 |

<%= message %>

42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/meter/meter.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import React from 'react'; 3 | import Knob from 'jquery-knob'; 4 | import {Widget} from 'kitto'; 5 | 6 | import './meter.scss'; 7 | 8 | class Meter extends Widget { 9 | componentDidMount() { 10 | this.state = { value: 0 }; 11 | this.$node = $(ReactDOM.findDOMNode(this)); 12 | this.$meter = this.$node.find('.meter'); 13 | this.$meter.attr('data-bgcolor', this.$meter.css('background-color')); 14 | this.$meter.attr('data-fgcolor', this.$meter.css('color')); 15 | this.$meter.knob(); 16 | } 17 | componentDidUpdate() { 18 | this.$meter.val(this.state.value); 19 | this.$meter.trigger('change'); 20 | } 21 | render() { 22 | return ( 23 |
24 |

{this.props.title}

25 | 33 |

{this.updatedAt(this.state.updated_at)}

34 |
35 | ); 36 | } 37 | }; 38 | 39 | Widget.mount(Meter); 40 | export default Meter; 41 | -------------------------------------------------------------------------------- /test/support/mix_generator_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.MixGeneratorHelper do 2 | @moduledoc """ 3 | Helpers to use when testing Mix tasks 4 | """ 5 | import ExUnit.Assertions 6 | import Mock 7 | alias Kitto.Mix.GeneratorMock 8 | 9 | def assert_creates_file(file, task) do 10 | with_mock Mix.Generator, [create_file: &GeneratorMock.create_file/2, create_directory: &GeneratorMock.create_directory/1] do 11 | created_file = task.() 12 | match? = cond do 13 | is_binary(file) -> created_file == file 14 | Regex.regex?(file) -> created_file =~ file 15 | end 16 | assert match?, "Expected #{inspect file} to be created. Created #{created_file} instead." 17 | end 18 | end 19 | 20 | def assert_creates_directory(dir, task) do 21 | with_mock Mix.Generator, [create_file: &GeneratorMock.create_file/2, create_directory: &GeneratorMock.create_directory/1] do 22 | created_dir = task.() 23 | match? = cond do 24 | is_binary(dir) -> created_dir == dir 25 | Regex.regex?(dir) -> created_dir =~ dir 26 | end 27 | assert match?, "Expected #{inspect dir} to be created. Created #{created_dir} instead." 28 | end 29 | end 30 | end 31 | 32 | defmodule Kitto.Mix.GeneratorMock do 33 | def create_directory(dir) do 34 | dir 35 | end 36 | 37 | def create_file(file, _contents) do 38 | file 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/list/list.scss: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Sass declarations 3 | // ---------------------------------------------------------------------------- 4 | $background-color: #12b0c5; 5 | $value-color: #fff; 6 | 7 | $title-color: rgba(255, 255, 255, 0.7); 8 | $label-color: rgba(255, 255, 255, 0.7); 9 | $moreinfo-color: rgba(255, 255, 255, 0.7); 10 | 11 | // ---------------------------------------------------------------------------- 12 | // Widget-list styles 13 | // ---------------------------------------------------------------------------- 14 | .widget-list { 15 | 16 | background-color: $background-color; 17 | 18 | .title { 19 | color: $title-color; 20 | } 21 | 22 | ol, ul { 23 | margin: 0 15px; 24 | text-align: left; 25 | color: $label-color; 26 | } 27 | 28 | ol { 29 | list-style-position: inside; 30 | } 31 | 32 | li { 33 | margin-bottom: 5px; 34 | } 35 | 36 | .list-nostyle { 37 | list-style: none; 38 | } 39 | 40 | .label { 41 | color: $label-color; 42 | } 43 | 44 | .value { 45 | float: right; 46 | margin-left: 12px; 47 | font-weight: 600; 48 | color: $value-color; 49 | } 50 | 51 | .updated-at { 52 | color: rgba(0, 0, 0, 0.3); 53 | } 54 | 55 | .more-info { 56 | color: $moreinfo-color; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/mix/tasks/generators/kitto.gen.widget.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.Gen.Widget do 2 | use Mix.Task 3 | import Mix.Generator 4 | import Kitto.Generator 5 | 6 | @shortdoc "Generates a new widget" 7 | 8 | @templates Path.join Path.expand("./templates", __DIR__), "widget" 9 | 10 | @moduledoc """ 11 | Generates a new widget 12 | 13 | Usage: 14 | 15 | $ mix kitto.gen.widget this_widget 16 | # generates `widgets/this_widget/this_widget.js` and 17 | # `widgets/this_widget/this_widget.scss` 18 | """ 19 | 20 | @doc false 21 | def run(argv) do 22 | {opts, args, _} = parse_options(argv) 23 | case List.first(args) do 24 | nil -> 25 | Mix.shell.error """ 26 | Usage: 27 | 28 | mix kitto.gen.widget this_widget 29 | """ 30 | Mix.raise "No widget name provided" 31 | widget -> 32 | widget_dir = Path.join(opts[:path] || "widgets", widget) 33 | create_directory widget_dir 34 | create_file Path.join(widget_dir, "#{widget}.scss"), EEx.eval_file(scss(), [name: widget]) 35 | create_file Path.join(widget_dir, "#{widget}.js"), EEx.eval_file( 36 | javascript(), [name: widget, class: classify(widget)] 37 | ) 38 | end 39 | end 40 | 41 | defp classify(widget), do: Macro.camelize(widget) 42 | 43 | defp javascript, do: Path.join(@templates, "widget.js.eex") 44 | defp scss, do: Path.join(@templates, "widget.scss.eex") 45 | end 46 | -------------------------------------------------------------------------------- /installer/templates/new/jobs/stats.exs: -------------------------------------------------------------------------------- 1 | use Kitto.Job.DSL 2 | 3 | job :job_failures, every: :second do 4 | stats = Kitto.StatsServer.stats 5 | 6 | failures = stats 7 | |> Enum.map(fn ({name, m}) -> %{label: name, value: m[:failures]} end) 8 | |> Enum.sort(fn (a, b) -> a[:value] > b[:value] end) 9 | |> Enum.take(15) 10 | 11 | broadcast! %{items: failures} 12 | end 13 | 14 | job :job_avg_time, every: {500, :milliseconds} do 15 | stats = Kitto.StatsServer.stats 16 | 17 | metrics = stats 18 | |> Enum.map(fn ({name, m}) -> 19 | %{label: name, value: m[:avg_time_took] |> Float.round(3) } 20 | end) 21 | |> Enum.sort(fn (a, b) -> a[:value] > b[:value] end) 22 | |> Enum.take(15) 23 | 24 | broadcast! %{items: metrics} 25 | end 26 | 27 | job :jobs_running, every: {200, :milliseconds} do 28 | stats = Kitto.StatsServer.stats 29 | |> Enum.filter(fn ({_name, m}) -> 30 | (m[:times_completed] + m[:failures]) < m[:times_triggered] 31 | end) 32 | |> length 33 | 34 | broadcast! %{value: stats} 35 | end 36 | 37 | job :footprint, every: :second do 38 | mem = :erlang.memory[:processes_used] / 1024 / 1024 |> Float.round(3) 39 | 40 | broadcast! :processes_count, %{value: length(:erlang.processes)} 41 | broadcast! :memory_usage, %{value: mem} 42 | end 43 | 44 | job :uptime, every: :second do 45 | hours = ((:erlang.statistics(:wall_clock) |> elem(0)) / 1000 / 60.0 / 60.0) 46 | |> Float.round(3) 47 | 48 | broadcast! %{value: hours} 49 | end 50 | -------------------------------------------------------------------------------- /test/support/file_assertion_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.FileAssertionHelper do 2 | @moduledoc """ 3 | Original code from the Phoenix Framework 4 | https://github.com/phoenixframework/phoenix/blob/master/installer/test/mix_helper.exs 5 | """ 6 | import ExUnit.Assertions 7 | import ExUnit.CaptureIO 8 | 9 | def assert_file(file) do 10 | assert File.regular?(file), "Expected #{file} to exist, but does not" 11 | end 12 | 13 | def assert_file(file, match) do 14 | cond do 15 | is_list(match) -> 16 | assert_file file, &(Enum.each(match, fn(m) -> assert &1 =~ m end)) 17 | is_binary(match) or Regex.regex?(match) -> 18 | assert_file file, &(assert &1 =~ match) 19 | is_function(match, 1) -> 20 | assert_file(file) 21 | match.(File.read!(file)) 22 | end 23 | end 24 | 25 | def refute_file(file) do 26 | refute File.regular?(file), "Expected #{file} to not exist, but it does" 27 | end 28 | 29 | def tmp_path, do: System.tmp_dir() 30 | 31 | def in_tmp(which, function) do 32 | path = Path.join(tmp_path(), which) 33 | File.rm_rf! path 34 | File.mkdir_p! path 35 | File.cd! path, function 36 | end 37 | 38 | def in_project(app, path, fun) do 39 | %{name: name, file: file} = Mix.Project.pop 40 | 41 | try do 42 | capture_io :stderr, fn -> Mix.Project.in_project(app, path, [], fun) end 43 | after 44 | Mix.Project.push(name, file) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.ViewTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | describe ".render/1" do 6 | test "renders layout with the given assigns" do 7 | html = "

Hello from kitto

\n\n
\n" 8 | 9 | assert Kitto.View.render("sample", color: "blue") == html 10 | end 11 | 12 | test "renders template with the given assigns" do 13 | html = "

Hello from kitto

" <> 14 | "\n\n

Hello from contributor

\n\n
\n" 15 | assigns = [color: "green", user: "contributor"] 16 | 17 | assert Kitto.View.render("sample", assigns) == html 18 | end 19 | end 20 | 21 | describe ".exists?/1" do 22 | test "when the template exists, returns true" do 23 | assert Kitto.View.exists?("sample") 24 | end 25 | 26 | test "when the template does not exist, returns false" do 27 | refute Kitto.View.exists?("does_not_exist") 28 | end 29 | 30 | test "when the template is not in dashboards, returns false" do 31 | refute Kitto.View.exists?("../../../mix.exs") 32 | refute Kitto.View.exists?("/etc/passwd") 33 | refute Kitto.View.exists?("../../../mix.exs\0") 34 | refute Kitto.View.exists?("/etc/passwd\0") 35 | end 36 | 37 | test "when the template is in a subdirectory of dashboards, returns true" do 38 | assert Kitto.View.exists?("folder/sample") 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /priv/static/helpers.js: -------------------------------------------------------------------------------- 1 | export default { 2 | updatedAt: function(value) { 3 | if (!value) { return; } 4 | 5 | let timestamp = new Date(value * 1000); 6 | let hours = timestamp.getHours(); 7 | let minutes = ("0" + timestamp.getMinutes()).slice(-2); 8 | let seconds = ("0" + timestamp.getSeconds()).slice(-2); 9 | 10 | return `Last updated at ${hours}:${minutes}:${seconds}`; 11 | }, 12 | shortenedNumber: function(num) { 13 | if (isNaN(num)) { return num; } 14 | if (num >= 1000000000) { return (num / 1000000000).toFixed(1) + 'B'; } 15 | if (num >= 1000000) { return (num / 1000000).toFixed(1) + 'M'; } 16 | if (num >= 1000) { return (num / 1000).toFixed(1) + 'K'; } 17 | 18 | return num; 19 | }, 20 | prettyNumber: function(num) { 21 | return isNaN(num) ? '' : num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); 22 | }, 23 | prepend: function(value, string) { 24 | return (string != null ? string : '') + (value != null ? value : ''); 25 | }, 26 | append: function(value, string) { 27 | return (value != null ? value : '') + (string != null ? string : ''); 28 | }, 29 | truncate: function(text, limit, omission) { 30 | if (!(typeof text === 'string')) { text = text + ""; } 31 | if (!(typeof limit === 'number') || isNaN(limit)) { limit = 30; } 32 | if (!(typeof omission === 'string')) { omission = '…'; } 33 | 34 | if (text.length <= limit) { return text; } 35 | 36 | return `${text.substr(0, limit)}${omission}`; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/graph/graph.scss: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // Sass declarations 3 | // ---------------------------------------------------------------------------- 4 | $background-color: #dc5945; 5 | 6 | $title-color: rgba(255, 255, 255, 0.7); 7 | $moreinfo-color: rgba(255, 255, 255, 0.3); 8 | $tick-color: rgba(0, 0, 0, 0.4); 9 | 10 | 11 | // ---------------------------------------------------------------------------- 12 | // Widget-graph styles 13 | // ---------------------------------------------------------------------------- 14 | .widget-graph { 15 | 16 | background-color: $background-color; 17 | position: relative; 18 | 19 | 20 | svg { 21 | position: absolute; 22 | opacity: 0.4; 23 | fill-opacity: 0.4; 24 | left: 0px; 25 | top: 0px; 26 | } 27 | 28 | .title, .value { 29 | position: relative; 30 | z-index: 99; 31 | } 32 | 33 | .title { 34 | color: $title-color; 35 | } 36 | 37 | .more-info { 38 | color: $moreinfo-color; 39 | font-weight: 600; 40 | font-size: 20px; 41 | margin-top: 0; 42 | } 43 | 44 | .x_tick { 45 | position: absolute; 46 | bottom: 0; 47 | .title { 48 | font-size: 20px; 49 | color: $tick-color; 50 | opacity: 0.5; 51 | padding-bottom: 3px; 52 | } 53 | } 54 | 55 | .y_ticks { 56 | font-size: 20px; 57 | fill: $tick-color; 58 | fill-opacity: 1; 59 | } 60 | 61 | .domain { 62 | display: none; 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/list/list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Widget, Helpers} from 'kitto'; 3 | 4 | import './list.scss'; 5 | 6 | class ListItem extends React.Component { 7 | render() { 8 | return ( 9 |
  • 10 | 11 | {Helpers.truncate(this.props.label, this.props.labelLength || 80)} 12 | 13 | 14 | {Helpers.truncate(this.props.value, this.props.valueLength)} 15 | 16 |
  • 17 | ); 18 | } 19 | } 20 | 21 | export class List extends Widget { 22 | renderItems(items) { 23 | return items.map((item, i) => { 24 | return ; 29 | }); 30 | } 31 | renderList(items) { 32 | return this.props.unordered ?
      {items}
    :
      {items}
    ; 33 | } 34 | render() { 35 | return ( 36 |
    37 |

    {this.props.title}

    38 |

    {this.props.text}

    39 |
      40 | {this.renderList(this.renderItems(this.state.items || []))} 41 |
    42 |

    {this.props.moreinfo}

    43 |

    {this.updatedAt(this.state.updated_at)}

    44 |
    45 | ); 46 | } 47 | }; 48 | 49 | Widget.mount(List); 50 | export default List; 51 | -------------------------------------------------------------------------------- /test/time_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.TimeTest do 2 | use ExUnit.Case, async: true 3 | doctest Kitto.Time 4 | 5 | test "#mseconds(:day)", do: assert Kitto.Time.mseconds(:day) == 24 * 3600 * 1000 6 | test "#mseconds(:hour)", do: assert Kitto.Time.mseconds(:hour) == 3600 * 1000 7 | test "#mseconds(:minute)", do: assert Kitto.Time.mseconds(:minute) == 60 * 1000 8 | test "#mseconds(:second)", do: assert Kitto.Time.mseconds(:second) == 1000 9 | test "#mseconds(nil)", do: assert Kitto.Time.mseconds(nil) == nil 10 | 11 | test "#mseconds({n, :days})" do 12 | assert Kitto.Time.mseconds({2, :days}) == 2 * 24 * 3600 * 1000 13 | end 14 | 15 | test "#mseconds({1, :day})" do 16 | assert Kitto.Time.mseconds({1, :day}) == 1 * 24 * 3600 * 1000 17 | end 18 | 19 | test "#mseconds({n, :hours})" do 20 | assert Kitto.Time.mseconds({4, :hours}) == 4 * 3600 * 1000 21 | end 22 | 23 | test "#mseconds({1, :hour})" do 24 | assert Kitto.Time.mseconds({1, :hour}) == 1 * 3600 * 1000 25 | end 26 | 27 | test "#mseconds({n, :minutes})" do 28 | assert Kitto.Time.mseconds({5, :minutes}) == 5 * 60 * 1000 29 | end 30 | 31 | test "#mseconds({1, :minute})" do 32 | assert Kitto.Time.mseconds({1, :minutes}) == 1 * 60 * 1000 33 | end 34 | 35 | test "#mseconds({n, :seconds})" do 36 | assert Kitto.Time.mseconds({7, :seconds}) == 7 * 1000 37 | end 38 | 39 | test "#mseconds({n, :second})" do 40 | assert Kitto.Time.mseconds({1, :second}) == 1 * 1000 41 | end 42 | 43 | test "#mseconds({n, :milliseconds})" do 44 | assert Kitto.Time.mseconds({9, :milliseconds}) == 9 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /installer/templates/new/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "<%= application_name %>", 3 | "version": "0.0.1", 4 | "description": "Dashboard Application", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "14.15.1", 8 | "npm": "6.14.9" 9 | }, 10 | "scripts": { 11 | "build": "./node_modules/.bin/webpack", 12 | "start": "./node_modules/.bin/webpack serve --progress", 13 | "heroku-postbuild": "npm run build" 14 | }, 15 | "keywords": [ 16 | "webpack" 17 | ], 18 | "author": "", 19 | "license": "MIT", 20 | "dependencies": { 21 | "kitto": "file:<%= npm_kitto_dep %>", 22 | "webpack": "^5.9.0", 23 | "webpack-merge": "^0.15.0", 24 | "compression-webpack-plugin": "7.0.0", 25 | "@babel/core": "^7.12.9", 26 | "babel-loader": "^8.2.2", 27 | "babel-preset-es2015": "6.18.0", 28 | "@babel/preset-react": "^7.12.7", 29 | "babel-preset-react-hmre": "^1.1.0", 30 | "sass-loader": "^10.1.0", 31 | "url-loader": "^4.1.1", 32 | "file-loader": "^6.2.0", 33 | "style-loader": "^0.13.0", 34 | "script-loader": "^0.7.0", 35 | "imports-loader": "0.6.5", 36 | "expose-loader": "0.7.1", 37 | "css-loader": "^0.9.1", 38 | "node-sass": "^5.0.0", 39 | "react": "^0.14.7", 40 | "react-dom": "^0.14.7", 41 | "glob": "7.1.0", 42 | "font-awesome": "^4.7.0", 43 | "d3": "3.5.17", 44 | "gridster": "0.5.6", 45 | "imports": "^1.0.0", 46 | "rickshaw": "1.6.0", 47 | "jquery": "^3.0.0", 48 | "jquery-knob": "1.2.11", 49 | "fscreen": "^1.0.2" 50 | }, 51 | "devDependencies": { 52 | "webpack-cli": "^4.2.0", 53 | "webpack-dev-server": "^3.11.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/kitto/job.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Job do 2 | @moduledoc """ 3 | Contains functions to run jobs based on their specified options. 4 | """ 5 | 6 | alias Kitto.Runner 7 | 8 | @doc """ 9 | Starts a job process 10 | """ 11 | def start_link(job) do 12 | pid = spawn_link(Kitto.Job, :new, [job]) 13 | pid |> Process.register(job.name) 14 | 15 | {:ok, pid} 16 | end 17 | 18 | @doc """ 19 | Registers a job to be started as a process by the runner supervisor 20 | """ 21 | @spec register(pid(), atom(), keyword(), map(), (() -> any())) :: map() 22 | def register(server, name, options, definition, job) do 23 | import Kitto.Time 24 | 25 | opts = [interval: options[:every] |> mseconds, 26 | first_at: options[:first_at] |> mseconds] 27 | 28 | Runner.register server, %{name: name, job: job, options: opts, definition: definition} 29 | end 30 | 31 | @doc """ 32 | Runs the job based on the given options 33 | """ 34 | @spec new(map()) :: no_return() 35 | def new(job) do 36 | case job.options[:interval] do 37 | nil -> once(job) 38 | _ -> with_interval(job) 39 | end 40 | end 41 | 42 | defp with_interval(job) do 43 | first_at(job[:options][:first_at], job) 44 | 45 | receive do 46 | after 47 | job.options[:interval] -> 48 | run job 49 | with_interval(put_in(job.options[:first_at], false)) 50 | end 51 | end 52 | 53 | defp run(job), do: Kitto.StatsServer.measure(job) 54 | 55 | defp once(job) do 56 | run job 57 | 58 | receive do 59 | end 60 | end 61 | 62 | defp first_at(false, _job), do: nil 63 | defp first_at(t, job) do 64 | if t, do: :timer.sleep(t) 65 | 66 | run job 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/number/number.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Widget} from 'kitto'; 3 | 4 | import './number.scss'; 5 | 6 | class Number extends Widget { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.state = { value: 0 }; 11 | this.lastValue = 0; 12 | } 13 | componentWillUpdate(_props, lastState) { 14 | this.lastValue = this.state.value; 15 | } 16 | decorateValue(value) { 17 | let number = this.shortenedNumber(this.state.value); 18 | 19 | return this.append(this.prepend(number, this.props.prefix), this.props.suffix); 20 | } 21 | arrow() { 22 | if (this.state.value > this.lastValue) { 23 | return (); 24 | } else { 25 | return (); 26 | } 27 | } 28 | difference() { 29 | if (this.lastValue && this.lastValue !== 0) { 30 | let normalized = (this.state.value - this.lastValue) / this.lastValue * 100; 31 | return `${Math.abs(Math.round(normalized))}%` 32 | } else { 33 | return ''; 34 | } 35 | } 36 | changeRate() { 37 | if (this.props.changerate == "off") { return; } 38 | 39 | return ( 40 |

    41 | {this.arrow()}{this.difference()} 42 |

    43 | ); 44 | } 45 | render() { 46 | return ( 47 |
    48 |

    {this.props.title}

    49 |

    {this.decorateValue(this.state.value)}

    50 |

    {this.props.moreinfo}

    51 | {this.changeRate()} 52 |

    {this.updatedAt(this.state.updated_at)}

    53 |
    54 | ); 55 | } 56 | }; 57 | 58 | Widget.mount(Number); 59 | export default Number; 60 | -------------------------------------------------------------------------------- /lib/kitto/plugs/authentication.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Plugs.Authentication do 2 | @moduledoc """ 3 | Defines authentication logic for routes that require it. 4 | Authentication uses token based auth with the Authentication header. 5 | 6 | ## Setting up authentication: 7 | 8 | To configure the dashboard with authentication, add the expected auth token to 9 | your application's config: 10 | 11 | # config/config.exs 12 | config :kitto, auth_token: "asecret" 13 | 14 | ## Authenticating requests 15 | 16 | To authenticate requests that require it, pass the auth token in the 17 | Authentication header of the request: 18 | 19 | Authentication: Token asecret 20 | 21 | An example cURL request to reload all dashboards with authentication: 22 | 23 | $ curl -H "Authentication: Token asecret" -X POST http://localhost:4000/dashboards 24 | 25 | ## Marking routes as authenticated 26 | 27 | When adding new routes, to mark them as authenticated, add the `authenticated` key 28 | to the route's private config: 29 | 30 | get "my/authenticated/route", private: %{authenticated: true} do 31 | # Process normal request 32 | end 33 | """ 34 | 35 | import Plug.Conn 36 | 37 | def init(opts), do: opts 38 | 39 | def call(conn, _) do 40 | if authentication_required?(conn) && !authenticated?(conn) do 41 | conn |> send_resp(401, "Authorization required") |> halt 42 | else 43 | conn 44 | end 45 | end 46 | 47 | defp authentication_required?(conn) do 48 | !!auth_token() && conn.private[:authenticated] 49 | end 50 | 51 | defp authenticated?(conn) do 52 | auth_token() == conn 53 | |> get_req_header("authentication") 54 | |> List.first 55 | |> to_string 56 | |> String.replace(~r/^Token\s/, "") 57 | end 58 | 59 | defp auth_token, do: Application.get_env(:kitto, :auth_token) 60 | end 61 | -------------------------------------------------------------------------------- /installer/templates/new/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | config :kitto, root: Path.dirname(__DIR__), port: 4000, otp_app: :<%= application_name %> 9 | 10 | # Use reload_code?: false to disable code reloading in development environment 11 | # Read More: https://github.com/kittoframework/kitto/wiki/Code-Reloading 12 | 13 | # Use ip: {:system, "KITTO_IP"} to have binding ip configurable via env variable 14 | # Example: `KITTO_IP=0.0.0.0 mix kitto.server` will start the server on 0.0.0.0 15 | 16 | # Use port: {:system, "KITT0_PORT"} to have port port configurable via env variable 17 | # Example: `KITTO_PORT=4444 mix kitto.server` will start the server on port 4444 18 | 19 | # Change the binding ip of the asset watcher server 20 | # config :kitto, assets_host: "127.0.0.1" 21 | 22 | # Change the binding port of the asset watcher server 23 | # config :kitto, assets_port: 8080 24 | 25 | # Use serve_assets?: true to enable serving static assets 26 | # config :kitto, serve_assets?: true 27 | # 28 | # Use :assets_path to configure the directory from which static assets are served 29 | # config :kitto, assets_path: "priv/static" 30 | # 31 | # Use default_dashboard: "your-dashboard" to specify the dashboard to be served 32 | # when the root path is requested. 33 | 34 | # Configures Elixir's Logger 35 | config :logger, :console, 36 | format: "$time $metadata[$level] $message\n", 37 | metadata: [:request_id] 38 | 39 | # Add authentication to `POST /widgets/:id`. Change "asecret" to something more 40 | # secure. 41 | # 42 | # config :kitto, :auth_token: "asecret" 43 | 44 | # Load environment specific configuration 45 | import_config "#{Mix.env}.exs" 46 | -------------------------------------------------------------------------------- /installer/templates/new/dashboards/sample.html.eex: -------------------------------------------------------------------------------- 1 |
    2 |
      3 |
    • 4 |
      10 |
    • 11 | 12 |
    • 16 |
      22 |
    • 23 | 24 |
    • 28 |
      34 |
    • 35 | 36 |
    • 40 |
      46 |
    • 47 | 48 |
    • 52 |
      57 |
    • 58 |
    59 |
    60 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.9.2" 5 | 6 | def project do 7 | [app: :kitto, 8 | version: @version, 9 | elixir: "~> 1.4", 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | description: description(), 13 | package: package(), 14 | deps: deps(), 15 | dialyzer: [ 16 | flags: [:unmatched_returns, :error_handling, :race_conditions, :no_opaque, :underspecs], 17 | paths: ["_build/dev/lib/kitto/ebin"]], 18 | test_coverage: [tool: ExCoveralls], 19 | preferred_cli_env: [ 20 | "coveralls": :test, 21 | "coveralls.detail": :test, 22 | "coveralls.post": :test, 23 | "coveralls.travis": :test, 24 | "coveralls.html": :test], 25 | name: "Kitto", 26 | source_url: "https://github.com/kittoframework/kitto", 27 | docs: [extras: ["README.md"], main: "readme"] 28 | ] 29 | end 30 | 31 | def application do 32 | [mod: {Kitto, []}, 33 | applications: [:logger]] 34 | end 35 | 36 | defp deps do 37 | [{:cowboy, "~> 1.0.0"}, 38 | {:plug, "~> 1.3.2"}, 39 | {:poison, "~> 3.0"}, 40 | {:fs, "~> 2.12.0"}, 41 | {:httpoison, "~> 0.11.1"}, 42 | {:ex_doc, "~> 0.14", only: :dev}, 43 | {:dialyxir, "~> 0.5", only: :dev, runtime: false}, 44 | {:credo, "~> 0.9", only: [:dev, :test]}, 45 | {:mock, "~> 0.2", only: :test}, 46 | {:excoveralls, "~> 0.5", only: :test}, 47 | {:inch_ex, "~> 0.5", only: :docs}] 48 | end 49 | 50 | defp description, do: "Framework for creating interactive dashboards" 51 | 52 | defp package do 53 | [ 54 | files: ["lib", "priv", "mix.exs", "package.json", "*.md"], 55 | maintainers: ["Dimitris Zorbas"], 56 | licenses: ["MIT"], 57 | links: %{github: "https://github.com/kittoframework/kitto"} 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/mix/tasks/kitto.server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.Server do 2 | use Mix.Task 3 | require Logger 4 | 5 | @watchers webpack: [bin: "./node_modules/.bin/webpack", 6 | opts: ["serve", "--stdin", "--progress"]] 7 | 8 | @shortdoc "Starts applications and their servers" 9 | 10 | @moduledoc """ 11 | Starts the application 12 | 13 | ## Command line options 14 | 15 | This task accepts the same command-line arguments as `run`. 16 | For additional information, refer to the documentation for 17 | `Mix.Tasks.Run`. 18 | 19 | The `--no-halt` flag is automatically added. 20 | """ 21 | def run(args) do 22 | if Kitto.watch_assets?(), do: spawn(&start_watcher/0) 23 | 24 | Mix.Task.run "run", run_args() ++ args 25 | end 26 | 27 | defp start_watcher do 28 | import Kitto, only: [asset_server_host: 0, asset_server_port: 0] 29 | 30 | validate_watcher() 31 | 32 | Logger.info "Starting assets watcher at: #{asset_server_host()}:#{asset_server_port()}" 33 | 34 | System.cmd watcher_bin(), 35 | watcher()[:opts], 36 | env: [{"KITTO_ASSETS_HOST", asset_server_host()}, 37 | {"KITTO_ASSETS_PORT", "#{Kitto.asset_server_port}"}] 38 | end 39 | 40 | defp validate_watcher do 41 | unless watcher_exists?() do 42 | Logger.error "Could not start watcher because #{watcher_bin()} could not " <> 43 | "be found. Your dashboard server is running, but assets won't " <> 44 | "be compiled." 45 | 46 | exit(:shutdown) 47 | end 48 | end 49 | 50 | defp watcher_exists?, do: File.exists? watcher_bin() 51 | 52 | defp watcher, do: Application.get_env(:kitto, :watcher, @watchers[:webpack]) 53 | defp watcher_bin, do: watcher()[:bin] |> Path.expand 54 | 55 | defp run_args, do: if iex_running?(), do: [], else: ["--no-halt"] 56 | defp iex_running?, do: Code.ensure_loaded?(IEx) && IEx.started? 57 | end 58 | -------------------------------------------------------------------------------- /lib/kitto/view.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.View do 2 | @moduledoc """ 3 | HTML rendering facility. 4 | 5 | This module defines functions to deal with EEx templates used to provide markup 6 | for the dashboards. 7 | 8 | ## Configuration Options 9 | * `templates_dir` - Where to look for templates, defaults to "dashboards". 10 | * `default_layout` - The layout in which to wrap dashboards, defaults to 11 | "layout" found in the `templates_dir`. 12 | """ 13 | 14 | @templates_dir Application.get_env :kitto, :templates_dir, "dashboards" 15 | @default_layout Application.get_env :kitto, :default_layout, "layout" 16 | 17 | @doc """ 18 | Returns the EEx compiled output of the layout with template specified 19 | """ 20 | def render(template, assigns \\ []) do 21 | @default_layout 22 | |> path 23 | |> EEx.eval_file(assigns: assigns ++ [template: render_template(template, assigns)]) 24 | end 25 | 26 | @doc """ 27 | Returns the EEx compiled output of the template specified 28 | """ 29 | def render_template(template, assigns \\ []) do 30 | template |> path |> EEx.eval_file(assigns: assigns) 31 | end 32 | 33 | @doc """ 34 | Returns the EEx compiled output of the error template 35 | """ 36 | def render_error(code, message) do 37 | "error" |> path |> EEx.eval_file([code: code, message: message]) 38 | end 39 | 40 | @doc """ 41 | Returns true if the given template exists in the templates directory 42 | """ 43 | def exists?(template) do 44 | file = template |> path 45 | File.exists?(file) && !(invalid_path?(template |> Path.split)) 46 | end 47 | 48 | defp path(template), do: Path.join(templates_path(), "#{template}.html.eex") 49 | defp templates_path, do: Path.join Kitto.root, @templates_dir 50 | 51 | defp invalid_path?([h|_]) when h in [".", "..", ""], do: true 52 | defp invalid_path?([h|t]), do: String.contains?(h, ["\\", ":", "\0"]) or invalid_path?(t) 53 | defp invalid_path?([]), do: false 54 | end 55 | -------------------------------------------------------------------------------- /installer/templates/new/rel/config.exs: -------------------------------------------------------------------------------- 1 | # Import all plugins from `rel/plugins` 2 | # They can then be used by adding `plugin MyPlugin` to 3 | # either an environment, or release definition, where 4 | # `MyPlugin` is the name of the plugin module. 5 | Path.join(["rel", "plugins", "*.exs"]) 6 | |> Path.wildcard() 7 | |> Enum.map(&Code.eval_file(&1)) 8 | 9 | use Mix.Releases.Config, 10 | # This sets the default release built by `mix release` 11 | default_release: :default, 12 | # This sets the default environment used by `mix release` 13 | default_environment: :prod 14 | 15 | # For a full list of config options for both releases 16 | # and environments, visit https://hexdocs.pm/distillery/configuration.html 17 | 18 | 19 | # You may define one or more environments in this file, 20 | # an environment's settings will override those of a release 21 | # when building in that environment, this combination of release 22 | # and environment configuration is called a profile 23 | 24 | environment :dev do 25 | set dev_mode: true 26 | set include_erts: false 27 | set cookie: :dev 28 | end 29 | 30 | environment :prod do 31 | set include_erts: true 32 | set include_src: false 33 | set cookie: :prod 34 | end 35 | 36 | # You may define one or more releases in this file. 37 | # If you have not set a default release, or selected one 38 | # when running `mix release`, the first release in the file 39 | # will be used by default 40 | 41 | release :<%= application_name %> do 42 | set version: current_version(:<%= application_name %>) 43 | set applications: [ 44 | :runtime_tools, 45 | :cowboy, 46 | :fs, 47 | :httpoison, 48 | :plug, 49 | :poison, 50 | :eex 51 | ] 52 | set plugins: [Kitto.CompileAssetsTask] 53 | set overlays: [ 54 | {:copy, "priv", "lib/<%%= release_name %>-<%%= release_version %>/priv"}, 55 | {:copy, "dashboards", "lib/<%%= release_name %>-<%%= release_version %>/dashboards"}, 56 | {:copy, "jobs", "lib/<%%= release_name %>-<%%= release_version %>/jobs"} 57 | ] 58 | end 59 | 60 | -------------------------------------------------------------------------------- /installer/templates/new/widgets/graph/graph.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import React from 'react'; 3 | import 'd3'; 4 | import 'rickshaw'; 5 | import {Kitto, Widget} from 'kitto'; 6 | 7 | import './graph.scss'; 8 | 9 | class Graph extends Widget { 10 | static get defaultProps() { 11 | return { graphType: 'area' }; 12 | } 13 | 14 | componentDidMount() { 15 | this.$node = $(ReactDOM.findDOMNode(this)); 16 | this.current = 0; 17 | this.renderGraph(); 18 | } 19 | renderGraph() { 20 | let container = this.$node.parent(); 21 | let $gridster = $('.gridster'); 22 | let config = Kitto.config(); 23 | let widget_base_dimensions = config.widget_base_dimensions; 24 | let width = (widget_base_dimensions[0] * 25 | container.data('sizex')) + 5 * 2 * (container.data('sizex') - 1); 26 | let height = (widget_base_dimensions[1] * container.data('sizey')); 27 | 28 | this.graph = new Rickshaw.Graph({ 29 | element: this.$node[0], 30 | width: width, 31 | height: height, 32 | renderer: this.props.graphType, 33 | series: [{color: '#fff', data: [{ x: 0, y: 0 }]}] 34 | }); 35 | 36 | new Rickshaw.Graph.Axis.Time({ graph: this.graph }); 37 | new Rickshaw.Graph.Axis.Y({ graph: this.graph, 38 | tickFormat: Rickshaw.Fixtures.Number.formatKMBT }); 39 | this.graph.render(); 40 | } 41 | componentWillUpdate(_props, state) { 42 | this.graph.series[0].data = state.points; 43 | this.current = state.points[state.points.length -1].y; 44 | this.graph.render(); 45 | } 46 | currentValue() { 47 | return this.prettyNumber(this.prepend(this.current)); 48 | } 49 | render() { 50 | return ( 51 |
    52 |

    {this.props.title}

    53 |

    {this.currentValue()}

    54 |

    {this.props.moreinfo}

    55 |
    56 | ); 57 | } 58 | }; 59 | 60 | Widget.mount(Graph); 61 | export default Graph; 62 | -------------------------------------------------------------------------------- /installer/templates/new/dashboards/jobs.html.eex: -------------------------------------------------------------------------------- 1 |
    2 |
      3 |
    • 7 |
      12 |
    • 13 | 14 |
    • 18 |
      24 |
    • 25 | 26 |
    • 30 |
      35 |
    • 36 | 37 |
    • 41 |
      46 |
    • 47 | 48 |
    • 52 |
      58 |
    • 59 | 60 |
    • 64 |
      69 |
    • 70 |
    71 |
    72 | -------------------------------------------------------------------------------- /test/kitto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule KittoTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureLog 5 | 6 | setup do 7 | path = Application.get_env :kitto, :root 8 | 9 | on_exit fn -> 10 | Application.put_env :kitto, :root, path 11 | end 12 | end 13 | 14 | test "#root when the :root config is set to a bitstring, it returns it" do 15 | path = "somewhere" 16 | Application.put_env :kitto, :root, path 17 | 18 | assert Kitto.root == path 19 | end 20 | 21 | test "#root when the :root config is set to :otp_app, returns the app_dir" do 22 | Application.put_env :kitto, :root, :otp_app 23 | 24 | assert Kitto.root == Application.app_dir(:kitto) 25 | end 26 | 27 | test "#root when the :root config is set to a non-bitstring, it raises error" do 28 | num = 42 29 | Application.put_env :kitto, :root, num 30 | 31 | assert catch_error(Kitto.root) == {:case_clause, num} 32 | end 33 | 34 | test "#root when the :root config is not set, it logs config info and exits" do 35 | Application.delete_env :kitto, :root 36 | 37 | assert capture_log(fn -> 38 | catch_exit(Kitto.root) == :shutdown 39 | end) =~ "config :root is nil." 40 | end 41 | 42 | test "#asset_server_host when the :assets_host is set, it returns it" do 43 | ip = "0.0.0.0" 44 | 45 | Application.put_env :kitto, :assets_host, ip 46 | 47 | assert Kitto.asset_server_host == ip 48 | end 49 | 50 | test "#asset_server_host when the :assets_host is not set, it returns the default" do 51 | Application.delete_env :kitto, :assets_host 52 | 53 | assert Kitto.asset_server_host == "127.0.0.1" 54 | end 55 | 56 | test "#asset_server_port when the :assets_port is set, it returns it" do 57 | port = 1337 58 | 59 | Application.put_env :kitto, :assets_port, port 60 | 61 | assert Kitto.asset_server_port == port 62 | end 63 | 64 | test "#asset_server_port when the :assets_port is not set, it returns the default" do 65 | Application.delete_env :kitto, :assets_port 66 | 67 | assert Kitto.asset_server_port == 8080 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/mix/tasks/kitto.new_test.exs: -------------------------------------------------------------------------------- 1 | Code.compiler_options(ignore_module_conflict: true) 2 | Code.require_file "../../../installer/lib/kitto_new.ex", __DIR__ 3 | Code.compiler_options(ignore_module_conflict: false) 4 | 5 | defmodule Mix.Tasks.Kitto.NewTest do 6 | use ExUnit.Case, async: false 7 | import Plug.Test 8 | import Kitto.FileAssertionHelper 9 | 10 | setup do 11 | Mix.Task.clear 12 | # The shell asks to install npm and mix deps. 13 | # We will politely say not. 14 | send self(), {:mix_shell_input, :yes?, false} 15 | :ok 16 | end 17 | 18 | test "when --version is provided, returns the current version" do 19 | Mix.Tasks.Kitto.New.run(["--version"]) 20 | kitto_version = "Kitto v#{Mix.Project.config[:version]}" 21 | 22 | assert_received {:mix_shell, :info, [^kitto_version] } 23 | end 24 | 25 | test "fails when invalid application name is provided" do 26 | assert_raise Mix.Error, fn -> 27 | Mix.Tasks.Kitto.New.run(["dashboards@skidata"]) 28 | end 29 | end 30 | 31 | test "fails when only providing a switch" do 32 | assert_raise Mix.Error, fn -> 33 | Mix.Tasks.Kitto.New.run(["-b"]) 34 | end 35 | end 36 | 37 | describe "when creating a new project" do 38 | test "copies the files" do 39 | in_tmp 'bootstrap', fn -> 40 | Mix.Tasks.Kitto.New.run(["photo_dashboard"]) 41 | 42 | assert_received {:mix_shell, 43 | :info, 44 | ["* creating photo_dashboard/config/config.exs"]} 45 | end 46 | end 47 | 48 | test "new project works" do 49 | in_tmp 'bootstrap', fn -> 50 | Mix.Tasks.Kitto.New.run(["photo_dashboard"]) 51 | end 52 | 53 | path = Path.join(tmp_path(), "bootstrap/photo_dashboard") 54 | in_project :photo_dashboard, path, fn _ -> 55 | Mix.Task.clear 56 | Mix.Task.run "compile", ["--no-deps-check"] 57 | Mix.shell.flush 58 | 59 | {:ok, _} = Application.ensure_all_started(:photo_dashboard) 60 | 61 | # Request the dashboard page to make sure the app responds correctly 62 | request = conn(:get, "/dashboards/sample") 63 | assert %Plug.Conn{status: 200} = Kitto.Router.call(request, []) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/backoff_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.BackoffServerTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Kitto.BackoffServer, as: Subject 5 | 6 | @min 1 7 | 8 | setup do 9 | Subject.reset 10 | 11 | on_exit fn -> 12 | Application.delete_env :kitto, :job_min_backoff 13 | Application.delete_env :kitto, :job_max_backoff 14 | end 15 | end 16 | 17 | test "#succeed resets to 0 the backoff for a job" do 18 | Subject.succeed :italian_job 19 | 20 | assert Subject.get(:italian_job) == 0 21 | end 22 | 23 | test "#reset resets the state of the server to an empty map" do 24 | Subject.fail :failjob 25 | Subject.fail :otherjob 26 | Subject.succeed :successjob 27 | 28 | Subject.reset 29 | 30 | assert is_nil(Subject.get(:failjob)) 31 | assert is_nil(Subject.get(:otherjob)) 32 | assert is_nil(Subject.get(:successjob)) 33 | end 34 | 35 | test "#fail increases the backoff value exponentially (power of 2)" do 36 | Subject.fail :failjob 37 | 38 | val = Subject.get :failjob 39 | 40 | Subject.fail :failjob 41 | assert Subject.get(:failjob) == val * 2 42 | 43 | Subject.fail :failjob 44 | assert Subject.get(:failjob) == val * 4 45 | end 46 | 47 | test "#backoff! puts the current process to sleep for backoff time" do 48 | maxval = 100 49 | Application.put_env :kitto, :job_mix_backoff, 64 50 | Application.put_env :kitto, :job_max_backoff, maxval 51 | Subject.fail :failjob 52 | 53 | {time, _} = :timer.tc fn -> Subject.backoff! :failjob end 54 | 55 | assert_in_delta time / 1000, maxval, 5 56 | end 57 | 58 | describe "when :job_min_backoff is configured" do 59 | setup [:set_job_min_backoff] 60 | 61 | test "#fail initializes the backoff to the min value" do 62 | Subject.fail :failjob 63 | 64 | assert Subject.get(:failjob) == @min 65 | end 66 | end 67 | 68 | describe "when :job_min_backoff is not configured" do 69 | test "#fail initializes the backoff to the default min value" do 70 | Subject.fail :failjob 71 | 72 | assert Subject.get(:failjob) == Kitto.Time.mseconds(:second) 73 | end 74 | end 75 | 76 | defp set_job_min_backoff(_context) do 77 | Application.put_env :kitto, :job_min_backoff, @min 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/job/dsl_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Job.DSLTest do 2 | use ExUnit.Case, async: true 3 | use Kitto.Job.DSL 4 | 5 | test """ 6 | Calls to broadcast!/1 are transformed to broadcast!/2 using the job name as 7 | broadcast topic 8 | """ do 9 | ast = quote do 10 | job :valid, every: :second do 11 | broadcast! %{} 12 | end 13 | end 14 | 15 | expanded_ast = Macro.expand(ast, __ENV__) |> Macro.to_string 16 | 17 | assert expanded_ast |> String.match?(~r/broadcast!\(:valid, %{}\) end\)/) 18 | end 19 | 20 | test "Converts job name to atom if it's a string" do 21 | ast = quote do 22 | job "valid", every: :second do 23 | broadcast! :valid, %{} 24 | end 25 | end 26 | 27 | expanded_ast = Macro.expand(ast, __ENV__) |> Macro.to_string 28 | 29 | assert expanded_ast =~ ~r/Job.register\(binding\(\)\[:runner_server\], :valid/ 30 | end 31 | 32 | test "When piping data to broadcast!, railroading is enabled using the job name as broadcast topic" do 33 | ast = quote do 34 | job :valid, every: :second do 35 | Weather.in(:london) 36 | |> broadcast! 37 | end 38 | end 39 | 40 | expanded_ast = Macro.expand(ast, __ENV__) |> Macro.to_string 41 | 42 | assert expanded_ast |> String.match?(~r/Weather.in\(:london\) |> broadcast!\(:valid\) end\)/) 43 | end 44 | 45 | test "When piping data to broadcast!(), railroading is enabled using the job name as broadcast topic" do 46 | ast = quote do 47 | job :valid, every: :second do 48 | Weather.in(:london) 49 | |> broadcast!() 50 | end 51 | end 52 | 53 | expanded_ast = Macro.expand(ast, __ENV__) |> Macro.to_string 54 | 55 | assert expanded_ast |> String.match?(~r/Weather.in\(:london\) |> broadcast!\(:valid\) end\)/) 56 | end 57 | 58 | test "When piping data to broadcast! specifying job name, no transformations are made" do 59 | ast = quote do 60 | job :valid, every: :second do 61 | Weather.in(:london) 62 | |> broadcast!(:london_weather) 63 | end 64 | end 65 | 66 | expanded_ast = Macro.expand(ast, __ENV__) |> Macro.to_string 67 | 68 | assert expanded_ast |> String.match?(~r/Weather.in\(:london\) |> broadcast!\(:london_weather\) end\)/) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/kitto/code_reloader.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.CodeReloader do 2 | @moduledoc """ 3 | Handles reloading of code in development 4 | """ 5 | 6 | use GenServer 7 | 8 | alias Kitto.Runner 9 | 10 | @doc """ 11 | Starts the code reloader server 12 | """ 13 | def start_link(opts) do 14 | GenServer.start_link(__MODULE__, opts, name: opts[:name] || __MODULE__) 15 | end 16 | 17 | @doc false 18 | def init(opts) do 19 | if reload_code?() do 20 | :fs.start_link(:default_fs) 21 | :fs.subscribe(:default_fs) 22 | end 23 | 24 | {:ok, %{opts: opts}} 25 | end 26 | 27 | @doc """ 28 | Returns true when the code reloader is set to start 29 | See: https://github.com/kittoframework/kitto/wiki/Code-Reloading 30 | """ 31 | def reload_code?, do: Application.get_env(:kitto, :reload_code?, true) 32 | 33 | ### Callbacks 34 | 35 | # Linux inotify 36 | def handle_info({_pid, {:fs, :file_event}, {path, event}}, state) 37 | when event in [[:modified, :closed], [:created]], 38 | do: reload(path, state) 39 | 40 | def handle_info({_pid, {:fs, :file_event}, {path, [:deleted]}}, state), 41 | do: stop(path, state) 42 | 43 | # Mac fsevent 44 | def handle_info({_pid, {:fs, :file_event}, {path, [_, _, :modified, _]}}, state) do 45 | reload(path, state) 46 | end 47 | 48 | def handle_info({_pid, {:fs, :file_event}, {path, [_, :modified]}}, state) do 49 | reload(path, state) 50 | end 51 | 52 | def handle_info(_other, state) do 53 | {:noreply, state} 54 | end 55 | 56 | defp stop(path, state) do 57 | with file <- path |> to_string do 58 | if job?(file), do: Runner.stop_job(state.opts[:server], file) 59 | end 60 | 61 | {:noreply, state} 62 | end 63 | 64 | defp reload(path, state) do 65 | with file <- path |> to_string do 66 | cond do 67 | file |> job? -> Runner.reload_job(state.opts[:server], file) 68 | file |> lib? -> Mix.Tasks.Compile.Elixir.run ["--ignore-module-conflict"] 69 | true -> :noop # File not watched. 70 | end 71 | end 72 | 73 | {:noreply, state} 74 | end 75 | 76 | defp jobs_rexp, do: ~r/#{Kitto.Runner.jobs_dir}.+.*exs?$/ 77 | defp lib_rexp, do: ~r/#{Kitto.root}\/lib.+.*ex$/ 78 | 79 | defp lib?(path), do: String.match?(path, lib_rexp()) 80 | defp job?(path), do: path |> String.match?(jobs_rexp()) 81 | end 82 | -------------------------------------------------------------------------------- /priv/static/widget.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import React from 'react'; 3 | import Helpers from './helpers'; 4 | 5 | class Widget extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = {}; 10 | this.source = (this.props.source || this.constructor.name).toLowerCase(); 11 | Widget.listen(this, this.source); 12 | } 13 | 14 | static events() { 15 | if (this._events) { return this._events; } 16 | 17 | this._events = new EventSource(`/events?topics=${this.sources().join()}`); 18 | 19 | this._events.addEventListener('error', (e) => { 20 | let state = e.currentTarget.readyState; 21 | 22 | if (state === EventSource.CONNECTING || state === EventSource.CLOSED) { 23 | 24 | // Restart the dashboard 25 | setTimeout((() => window.location.reload()), 5 * 60 * 1000) 26 | } 27 | }); 28 | 29 | this.bindInternalEvents(); 30 | 31 | return this._events; 32 | } 33 | 34 | static sources() { 35 | return Array.prototype.map 36 | .call(document.querySelectorAll('[data-source]'), (el) => el.dataset.source); 37 | } 38 | 39 | static bindInternalEvents() { 40 | this._events.addEventListener('_kitto', (event) => { 41 | let data = JSON.parse(event.data); 42 | 43 | switch (data.message.event) { 44 | case 'reload': 45 | if (data.message.dashboard === '*' || 46 | document.location.pathname.endsWith(data.message.dashboard)) { 47 | document.location.reload() 48 | } 49 | 50 | break; 51 | } 52 | }); 53 | } 54 | 55 | static listen(component, source) { 56 | this.events().addEventListener((source.toLowerCase() || 'messages'), (event) => { 57 | component.setState(JSON.parse(event.data).message); 58 | }); 59 | } 60 | 61 | static mount(component) { 62 | const widgets = document.querySelectorAll(`[data-widget="${component.name}"]`) 63 | 64 | Array.prototype.forEach.call(widgets, (el) => { 65 | var dataset = el.dataset; 66 | 67 | dataset.className = `${el.className} widget-${component.name.toLowerCase()} widget`; 68 | ReactDOM.render(React.createElement(component, dataset), el.parentNode); 69 | }); 70 | } 71 | } 72 | 73 | for (var k in Helpers) { Widget.prototype[k] = Helpers[k]; } 74 | 75 | export default Widget; 76 | -------------------------------------------------------------------------------- /test/plugs/authentication_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.PlugAuthenticationTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | @opts Kitto.Plugs.Authentication.init([]) 6 | 7 | # NOTE: On tests that test whether the request is granted, the assertion is 8 | # a little bit awkward. The test specifies that what's expected is that the 9 | # connection should not be touched by the plug. Only when a request is 10 | # denied should the plug stop the connection. 11 | test "grants access when authenticated private param is not set" do 12 | conn = conn(:post, "/widgets") 13 | 14 | assert Kitto.Plugs.Authentication.call(conn, @opts) == conn 15 | end 16 | 17 | test "grants access when authenticated private param set to false" do 18 | conn = conn(:post, "/widgets") |> put_private(:authenticated, false) 19 | 20 | assert Kitto.Plugs.Authentication.call(conn, @opts) == conn 21 | end 22 | 23 | test "grants access when no auth_token set" do 24 | conn = conn(:post, "/widgets") |> put_private(:authenticated, true) 25 | 26 | assert Kitto.Plugs.Authentication.call(conn, @opts) == conn 27 | end 28 | 29 | test "grants access when auth token set without authenticated private param" do 30 | Application.put_env :kitto, :auth_token, "asecret" 31 | conn = conn(:post, "/dashboard") 32 | 33 | assert Kitto.Plugs.Authentication.call(conn, @opts) == conn 34 | Application.delete_env :kitto, :auth_token 35 | end 36 | 37 | test """ 38 | denies access when auth token and authenticated private param set without 39 | authorization header provided 40 | """ do 41 | Application.put_env :kitto, :auth_token, "asecret" 42 | conn = conn(:post, "/widgets") 43 | |> put_private(:authenticated, true) 44 | |> Kitto.Plugs.Authentication.call(@opts) 45 | 46 | assert conn.status == 401 47 | assert conn.state == :sent 48 | Application.delete_env :kitto, :auth_token 49 | end 50 | 51 | test """ 52 | grants access when auth token and authenticated private param set with 53 | authorization header provided 54 | """ do 55 | Application.put_env :kitto, :auth_token, "asecret" 56 | conn = conn(:post, "/widgets") 57 | |> put_private(:authenticated, true) 58 | |> put_req_header("authentication", "Token asecret") 59 | 60 | assert Kitto.Plugs.Authentication.call(conn, @opts) == conn 61 | Application.delete_env :kitto, :auth_token 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/kitto/backoff_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.BackoffServer do 2 | @moduledoc """ 3 | Module responsible for keeping and applying a backoff value 4 | for a given atom. 5 | 6 | ### Configuration 7 | 8 | * `:job_min_backoff` - The minimum time in milliseconds to backoff upon failure 9 | * `:job_max_backoff` - The maximum time in milliseconds to backoff upon failure 10 | """ 11 | 12 | @behaviour Kitto.Backoff 13 | 14 | use GenServer 15 | use Bitwise 16 | 17 | alias Kitto.Time 18 | 19 | @server __MODULE__ 20 | @minval Time.mseconds(:second) 21 | @maxval Time.mseconds({5, :minutes}) 22 | 23 | @doc false 24 | def start_link(opts) do 25 | GenServer.start_link(__MODULE__, opts, name: opts[:name] || __MODULE__) 26 | end 27 | 28 | @doc false 29 | def init(_), do: {:ok, %{}} 30 | 31 | @doc """ 32 | Resets the backoff for the given atom to 0 33 | """ 34 | @spec succeed(atom()) :: atom() 35 | def succeed(name), do: set(name, 0) 36 | 37 | @doc """ 38 | Increments the backoff value for the provided atom up to the 39 | configured maximum value. 40 | """ 41 | def fail(name) do 42 | case get(name) do 43 | nil -> set(name, min(minval(), maxval())) 44 | 0 -> set(name, min(minval(), maxval())) 45 | val -> set(name, min(val <<< 1, maxval())) 46 | end 47 | end 48 | 49 | @doc """ 50 | Makes the calling process sleep for the accumulated backoff time 51 | for the given atom 52 | """ 53 | @spec backoff!(atom()) :: :nop | :ok 54 | def backoff!(name), do: backoff!(name, name |> get) 55 | defp backoff!(_name, val) when is_nil(val) or val == 0, do: :nop 56 | defp backoff!(_name, val), do: :timer.sleep(val) 57 | 58 | @spec get(atom()) :: nil | non_neg_integer() 59 | def get(name), do: GenServer.call(@server, {:get, name}) 60 | 61 | @spec reset() :: nil 62 | def reset, do: GenServer.call(@server, :reset) 63 | 64 | ### Callbacks 65 | def handle_call(:reset, _from, _state), do: {:reply, nil, %{}} 66 | def handle_call({:get, name}, _from, state), do: {:reply, state[name], state} 67 | def handle_call({:set, name, value}, _from, state) do 68 | {:reply, name, put_in(state[name], value)} 69 | end 70 | 71 | defp set(name, value), do: GenServer.call(@server, {:set, name, value}) 72 | defp minval, do: Application.get_env(:kitto, :job_min_backoff, @minval) 73 | defp maxval, do: Application.get_env(:kitto, :job_max_backoff, @maxval) 74 | end 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you discover issues, have ideas for improvements or new features, 4 | please report them to the [issue tracker][issue-tracker] of the repository or 5 | submit a pull request. Please, try to follow these guidelines when you 6 | do so. 7 | 8 | ## Issue reporting 9 | 10 | * Check that the issue has not already been reported. 11 | * Check that the issue has not already been fixed in the latest code 12 | (a.k.a. `master`). 13 | * Be clear, concise and precise in your description of the problem. 14 | * Open an issue with a descriptive title and a summary in grammatically correct, 15 | complete sentences. Follow the format of [ISSUE_TEMPLATE.md][issue-template]. 16 | * Mention the version of the hex package you are using. 17 | * Include any relevant code to the issue summary. 18 | 19 | ## Pull requests 20 | 21 | * Read [how to properly contribute to open source projects on Github][fork-how]. 22 | * Fork the project. 23 | * Use a topic/feature branch to easily amend a pull request later, if necessary. 24 | * Comply with our [git style guide][git-style-guide]. 25 | * Make sure you are familiar with the tooling and technologies used in the 26 | project (Elixir, Mix, React, Webpack). 27 | * Use the same coding conventions as the rest of the project. 28 | * Commit and push until you are happy with your contribution. 29 | * Make sure to add tests for it. This is important so I don't break it 30 | in a future version unintentionally. 31 | * Add an entry to the [Changelog](CHANGELOG.md) accordingly (read: [packaging guidelines][packaging-guidelines]). 32 | * Make sure the test suite is passing and the code you wrote doesn't produce 33 | [credo][credo] offenses. 34 | * Do not to decrement the test coverage, unless absolutely necessary. 35 | * [Squash related commits together][squash-rebase] and rebase on upstream master. 36 | * Open a [pull request][using-pull-requests] that relates to *only* one subject 37 | with a clear title and description in grammatically correct, complete sentences. 38 | 39 | [issue-tracker]: https://github.com/kittoframework/kitto/issues 40 | [fork-how]: http://gun.io/blog/how-to-github-fork-branch-and-pull-request 41 | [git-style-guide]: https://github.com/agis-/git-style-guide 42 | [using-pull-requests]: https://help.github.com/articles/using-pull-requests 43 | [squash-rebase]: http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html 44 | [issue-template]: https://github.com/kittoframework/kitto/blob/master/ISSUE_TEMPLATE.md 45 | [credo]: https://github.com/rrrene/credo 46 | [packaging-guidelines]: https://zorbash.com/post/software-packaging-guidelines 47 | -------------------------------------------------------------------------------- /test/job_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.JobTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "#new calls the given job after the given interval" do 5 | pid = self() 6 | job = fn -> send pid, :ok end 7 | interval = 100 8 | 9 | spawn_link(Kitto.Job, :new, [%{name: :dummy_job, 10 | job: job, 11 | options: %{interval: interval}}]) 12 | 13 | :timer.sleep(interval + 10) 14 | assert_received :ok 15 | end 16 | 17 | test "#new does not call the given job before interval" do 18 | pid = self() 19 | job = fn -> send pid, :ok end 20 | interval = 100 21 | 22 | spawn_link(Kitto.Job, :new, [%{name: :dummy_job, 23 | job: job, 24 | options: %{interval: interval}}]) 25 | 26 | refute_received :ok 27 | end 28 | 29 | test "#new, with first_at option, calls job after first_at seconds" do 30 | pid = self() 31 | job = fn -> send pid, :ok end 32 | first_at = 100 33 | 34 | spawn_link(Kitto.Job, :new, [%{name: :dummy_job, 35 | job: job, 36 | options: %{first_at: first_at}}]) 37 | 38 | :timer.sleep(first_at + 10) 39 | 40 | assert_received :ok 41 | end 42 | 43 | test "#new, with first_at option, does not call job before first_at" do 44 | pid = self() 45 | job = fn -> send pid, :ok end 46 | first_at = 100 47 | 48 | spawn_link(Kitto.Job, :new, [%{name: :dummy_job, 49 | job: job, 50 | options: %{first_at: first_at}}]) 51 | 52 | refute_received :ok 53 | end 54 | 55 | test "#new, with first_at unspecified, calls job immediately" do 56 | pid = self() 57 | job = fn -> send pid, :ok end 58 | 59 | spawn_link(Kitto.Job, :new, [%{name: :dummy_job, 60 | job: job, 61 | options: %{}}]) 62 | 63 | :timer.sleep(10) 64 | 65 | assert_received :ok 66 | end 67 | 68 | test "#new, calls jobs multiple times" do 69 | pid = self() 70 | job = fn -> send pid, :ok end 71 | interval = 100 72 | 73 | spawn_link(Kitto.Job, :new, [%{name: :dummy_job, 74 | job: job, 75 | options: %{first_at: false, interval: interval}}]) 76 | 77 | receive do 78 | :ok -> 79 | receive do 80 | :ok -> 81 | receive do 82 | :ok -> :done 83 | after 84 | 130 -> raise "Job was not called within the expected time" 85 | end 86 | after 87 | 130 -> raise "Job was not called within the expected time" 88 | end 89 | after 90 | 130 -> raise "Job was not called within the expected time" 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:1.8-slim 2 | MAINTAINER Dimitris Zorbas "dimitrisplusplus@gmail.com" 3 | 4 | RUN mix local.hex --force && mix local.rebar --force 5 | RUN apt-get update \ 6 | && apt-get -qq install curl xz-utils git make gnupg ca-certificates curl wget gnupg dirmngr xz-utils libatomic1 --no-install-recommends \ 7 | && apt-get clean \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | ENV NPM_CONFIG_LOGLEVEL info 11 | ENV NODE_VERSION 14.15.1 12 | 13 | RUN groupadd --gid 1000 node \ 14 | && useradd --uid 1000 --gid node --shell /bin/bash --create-home node 15 | 16 | RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \ 17 | && case "${dpkgArch##*-}" in \ 18 | amd64) ARCH='x64';; \ 19 | ppc64el) ARCH='ppc64le';; \ 20 | s390x) ARCH='s390x';; \ 21 | arm64) ARCH='arm64';; \ 22 | armhf) ARCH='armv7l';; \ 23 | i386) ARCH='x86';; \ 24 | *) echo "unsupported architecture"; exit 1 ;; \ 25 | esac \ 26 | && set -ex \ 27 | # libatomic1 for arm 28 | && apt-get update && apt-get install -y ca-certificates curl wget gnupg dirmngr xz-utils libatomic1 --no-install-recommends \ 29 | && rm -rf /var/lib/apt/lists/* \ 30 | && for key in \ 31 | 4ED778F539E3634C779C87C6D7062848A1AB005C \ 32 | 94AE36675C464D64BAFA68DD7434390BDBE9B9C5 \ 33 | 1C050899334244A8AF75E53792EF661D867B9DFA \ 34 | 71DCFD284A79C3B38668286BC97EC7A07EDE3FC1 \ 35 | 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \ 36 | C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \ 37 | C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C \ 38 | DD8F2338BAE7501E3DD5AC78C273792F7D83545D \ 39 | A48C2BEE680E841632CD4E44F07496B3EB3C1762 \ 40 | 108F52B48DB57BB0CC439B2997B01419BD92F80A \ 41 | B9E2F5981AA6E0CD28160D9FF13993A75599653C \ 42 | ; do \ 43 | gpg --batch --keyserver hkp://p80.pool.sks-keyservers.net:80 --recv-keys "$key" || \ 44 | gpg --batch --keyserver hkp://ipv4.pool.sks-keyservers.net --recv-keys "$key" || \ 45 | gpg --batch --keyserver hkp://pgp.mit.edu:80 --recv-keys "$key" ; \ 46 | done \ 47 | && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" \ 48 | && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \ 49 | && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ 50 | && grep " node-v$NODE_VERSION-linux-$ARCH.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ 51 | && tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \ 52 | && rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \ 53 | && apt-mark auto '.*' > /dev/null \ 54 | && find /usr/local -type f -executable -exec ldd '{}' ';' \ 55 | | awk '/=>/ { print $(NF-1) }' \ 56 | | sort -u \ 57 | | xargs -r dpkg-query --search \ 58 | | cut -d: -f1 \ 59 | | sort -u \ 60 | | xargs -r apt-mark manual \ 61 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 62 | && ln -s /usr/local/bin/node /usr/local/bin/nodejs \ 63 | # smoke tests 64 | && node --version \ 65 | && npm --version 66 | -------------------------------------------------------------------------------- /priv/static/kitto.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import Gridster from 'jquery.gridster'; 3 | import fscreen from 'fscreen'; 4 | 5 | window.jQuery = window.$ = $; 6 | 7 | class Kitto { 8 | static start() { 9 | Kitto 10 | .initializeGridster() 11 | .initializeRotator() 12 | .initializeFullScreenButton(); 13 | } 14 | 15 | static config(config) { 16 | if (config) { 17 | $('.gridster').attr('kitto_config', JSON.stringify(config)); 18 | } else { 19 | return JSON.parse($('.gridster').attr('kitto_config')); 20 | } 21 | } 22 | 23 | static initializeGridster() { 24 | window.Gridster = Gridster; 25 | 26 | const $gridster = $('.gridster'); 27 | const resolution = $gridster.data('resolution'); 28 | let config = Kitto.calculateGridsterDimensions(resolution); 29 | 30 | Kitto.config(config); 31 | 32 | $gridster.width(config.content_width); 33 | 34 | $('.gridster > ul').gridster({ 35 | widget_margins: config.widget_margins, 36 | widget_base_dimensions: config.widget_base_dimensions, 37 | }); 38 | 39 | return this; 40 | } 41 | 42 | static calculateGridsterDimensions(resolution) { 43 | let config = {}; 44 | 45 | config.widget_base_dimensions = [300, 360]; 46 | config.widget_margins = [5, 5]; 47 | config.columns = 4; 48 | 49 | if (resolution == "1080") { 50 | config.widget_base_dimensions = [370, 340]; 51 | config.columns = 5; 52 | } 53 | 54 | config.content_width = 55 | (config.widget_base_dimensions[0] + 56 | config.widget_margins[0] * 2) * config.columns; 57 | 58 | return config; 59 | } 60 | 61 | // Rotates between dashboards 62 | // See: https://github.com/kittoframework/kitto/wiki/Cycling-Between-Dashboards 63 | static initializeRotator() { 64 | let $rotator = $('.rotator'); 65 | let $dashboards = $rotator.children(); 66 | 67 | if (!$rotator) { return this; } 68 | 69 | let current_dashboard_index = 0; 70 | let dashboard_count = $dashboards.length; 71 | let interval = $rotator.data('interval') * 1000; 72 | 73 | let rotate = () => { 74 | $dashboards.hide(); 75 | $($dashboards[current_dashboard_index]).show(); 76 | 77 | current_dashboard_index = (current_dashboard_index + 1) % dashboard_count; 78 | }; 79 | 80 | rotate(); 81 | setInterval(rotate, interval); 82 | 83 | return this; 84 | } 85 | 86 | static initializeFullScreenButton() { 87 | var timer; 88 | var $button = $('.fullscreen-button'); 89 | 90 | $('body').on('mousemove', function() { 91 | clearTimeout(timer); 92 | if (!$button.hasClass('active')) { $button.addClass('active') } 93 | timer = setTimeout(function() { $button.removeClass('active') }, 1000); 94 | }) 95 | 96 | $button.on('click', function() { 97 | fscreen.requestFullscreen(document.getElementById('container')); 98 | }) 99 | 100 | return this; 101 | } 102 | } 103 | 104 | let Widget = require('./widget').default; 105 | let Helpers = require('./helpers').default; 106 | 107 | export {Kitto, Widget, Helpers}; 108 | -------------------------------------------------------------------------------- /test/runner_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.RunnerTest do 2 | use ExUnit.Case 3 | 4 | require Logger 5 | 6 | import ExUnit.CaptureLog 7 | import Kitto.TestHelper, only: [wait_for: 1] 8 | 9 | alias Kitto.Runner 10 | 11 | @jobs_dir "test/fixtures/jobs" 12 | @valid_job Path.join(@jobs_dir, "valid_job.exs") |> Path.absname 13 | @updated_job Path.join(@jobs_dir, "updated_valid_job.file") |> Path.absname 14 | 15 | setup do 16 | Application.put_env :kitto, :jobs_dir, @jobs_dir 17 | valid_job = File.read! @valid_job 18 | 19 | on_exit fn -> 20 | Application.delete_env :kitto, :jobs_dir 21 | File.write! @valid_job, valid_job 22 | end 23 | end 24 | 25 | test "#jobs_dir returns the jobs directory" do 26 | assert Runner.jobs_dir == Path.join(System.cwd, @jobs_dir) 27 | end 28 | 29 | test "#register appends a job to the list of jobs" do 30 | Application.delete_env :kitto, :jobs_dir 31 | 32 | {:ok, runner} = Runner.start_link(name: :job_runner) 33 | job = %{name: :dummy} 34 | 35 | runner |> Runner.register(job) 36 | assert runner |> Runner.jobs == [job] 37 | end 38 | 39 | test "loads only valid jobs" do 40 | capture_log(fn -> 41 | {:ok, runner} = Runner.start_link(name: :job_runner, 42 | supervisor_name: :runner_sup) 43 | 44 | wait_for(:runner_sup) 45 | 46 | jobs = runner |> Runner.jobs 47 | 48 | assert Enum.map(jobs, &(&1.name)) == [:valid] 49 | end) 50 | end 51 | 52 | test "logs warning for jobs with syntax errors" do 53 | assert capture_log(fn -> 54 | {:ok, _runner} = Runner.start_link(name: :job_runner, 55 | supervisor_name: :runner_sup) 56 | 57 | wait_for(:runner_sup) 58 | end) =~ "syntax error(s) and will not be loaded" 59 | end 60 | 61 | test "#reload stops and starts jobs defined in the reloaded file" do 62 | capture_log fn -> 63 | {:ok, runner} = Runner.start_link(name: :job_runner, supervisor_name: :runner_sup) 64 | 65 | supervisor = wait_for(:runner_sup) 66 | job_before = Process.whereis(:valid) 67 | Process.monitor(job_before) 68 | 69 | File.write!(@valid_job, File.read!(@updated_job)) 70 | 71 | runner |> Runner.reload_job(@valid_job) 72 | 73 | receive do 74 | {:DOWN, _, _, ^job_before, _} -> 75 | job_after = wait_for(:updated_valid) 76 | [{child_name, _, _, _}] = supervisor |> Supervisor.which_children 77 | 78 | refute job_before == job_after 79 | assert child_name == :updated_valid 80 | end 81 | end 82 | end 83 | 84 | test "#stop_job stops all jobs defined in the reloaded file" do 85 | capture_log fn -> 86 | {:ok, runner} = Runner.start_link(name: :job_runner, supervisor_name: :runner_sup) 87 | 88 | wait_for(:runner_sup) 89 | job = Process.whereis(:valid) 90 | Process.monitor(job) 91 | 92 | runner |> Runner.stop_job(@valid_job) 93 | 94 | receive do 95 | {:DOWN, _, _, ^job, _} -> :ok 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Kitto Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [dimitrisplusplus@gmail.com](mailto:dimitrisplusplus@gmail.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org/), version 1.4, available at [http://contributor-covenant.org/version/1/4](). 44 | -------------------------------------------------------------------------------- /lib/kitto/job/dsl.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Job.DSL do 2 | @moduledoc """ 3 | A DSL to define jobs populating the widgets with data. 4 | """ 5 | 6 | alias Kitto.Job 7 | alias Kitto.Notifier 8 | 9 | @doc false 10 | defmacro __using__(_opts) do 11 | quote do 12 | import Kitto.Job.DSL 13 | import Kitto.Notifier, only: [broadcast!: 2] 14 | end 15 | end 16 | 17 | @doc """ 18 | Main API to define jobs. 19 | 20 | Jobs can either be defined with a block or a command. When using a block, the 21 | expression represents data retrieval and any transformations required to 22 | broadcast events to the widgets. With command, the stdout and exit code of 23 | the command will be broadcasted to the widgets using the jobs name as the 24 | data source. 25 | 26 | Data broadcast using commands is in the form `{exit_code: integer, stdout: String.t}` 27 | 28 | ## Examples 29 | 30 | use Kitto.Job.DSL 31 | 32 | job :jenkins, every: :minute do 33 | jobs = Jenkins.jobs |> Enum.map(fn (%{"job" => job}) -> %{job: job.status} end) 34 | 35 | broadcast! :jenkins, %{jobs: jobs} 36 | end 37 | 38 | job :twitter, do: Twitter.stream("#elixir", &(broadcast!(:twitter, &1)) 39 | 40 | job :echo, every: :minute, command: "echo hello" 41 | 42 | job :kitto_last_commit, 43 | every: {5, :minutes}, 44 | command: "curl https://api.github.com/repos/kittoframework/kitto/commits\?page\=1\&per_page\=1" 45 | 46 | 47 | ## Options 48 | * `:every` - Sets the interval on which the job will be performed. When it's not 49 | specified, the job will be called once (suitable for streaming resources). 50 | 51 | * `:first_at` - A timeout after which to perform the job for the first time 52 | 53 | * `:command` - A command to be run on the server which will automatically 54 | broadcast events using the jobs name. 55 | """ 56 | defmacro job(name, options, contents \\ []) do 57 | name = if is_atom(name), do: name, else: String.to_atom(name) 58 | if options[:command] do 59 | _job(:shell, name, options) 60 | else 61 | _job(:elixir, name, options, contents) 62 | end 63 | end 64 | 65 | defp _job(:elixir, name, options, contents) do 66 | block = Macro.prewalk (options[:do] || contents[:do]), fn 67 | {:|>, pipe_meta, [lhs, {:broadcast!, meta, context}]} when is_atom(context) or context == [] -> {:|>, pipe_meta, [lhs, {:broadcast!, meta, [name]}]} 68 | {:broadcast!, meta, args = [{:%{}, _, _}]} -> {:broadcast!, meta, [name] ++ args} 69 | ast_node -> ast_node 70 | end 71 | 72 | quote do 73 | Job.register binding()[:runner_server], 74 | unquote(name), 75 | unquote(options |> Keyword.delete(:do)), 76 | (__ENV__ |> Map.take([:file, :line])), 77 | fn -> unquote(block) end 78 | end 79 | end 80 | 81 | defp _job(:shell, name, options) do 82 | quote do 83 | command = unquote(options)[:command] 84 | block = fn -> 85 | [sh | arguments] = command |> String.split 86 | {stdout, exit_code} = System.cmd(sh, arguments) 87 | 88 | Notifier.broadcast!(unquote(name), %{stdout: stdout, exit_code: exit_code}) 89 | end 90 | 91 | Job.register binding()[:runner_server], 92 | unquote(name), 93 | unquote(options), 94 | (__ENV__ |> Map.take([:file, :line])), 95 | block 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/kitto.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto do 2 | @moduledoc """ 3 | This is the documentation for the Kitto project. 4 | 5 | You can find documentation about developing with Kitto and configuration 6 | options at the [wiki](https://github.com/kittoframework/kitto#support) 7 | 8 | By default, Kitto applications depend on the following packages: 9 | 10 | * [Plug](https://hexdocs.pm/plug) - a specification and conveniences 11 | for composable modules in between web applications 12 | * [Poison](https://hexdocs.pm/poison) - an Elixir JSON library 13 | """ 14 | 15 | use Application 16 | import Supervisor.Spec, warn: false 17 | require Logger 18 | 19 | @defaults %{ip: {127, 0, 0, 1}, port: 4000} 20 | 21 | def start(_type, _args) do 22 | opts = [strategy: :one_for_one, name: Kitto.Supervisor] 23 | 24 | Supervisor.start_link(children(), opts) 25 | end 26 | 27 | @spec start_server() :: {:ok, pid()} 28 | def start_server do 29 | Logger.info "Starting Kitto server, listening on #{ip_human(ip())}:#{port()}" 30 | {:ok, _pid} = Plug.Adapters.Cowboy.http(Kitto.Router, [], ip: ip(), port: port()) 31 | end 32 | 33 | @doc """ 34 | Returns the root path of the dashboard project 35 | """ 36 | @spec root() :: String.t() | no_return() 37 | def root do 38 | case Application.get_env(:kitto, :root) do 39 | :otp_app -> Application.app_dir(Application.get_env(:kitto, :otp_app)) 40 | path when is_bitstring(path) -> path 41 | nil -> 42 | """ 43 | Kitto config :root is nil. 44 | It should normally be set to Path.dirname(__DIR__) in config/config.exs 45 | """ |> Logger.error 46 | exit(:shutdown) 47 | end 48 | end 49 | 50 | @doc """ 51 | Returns true when the asset development server is set to be watching for changes 52 | """ 53 | @spec watch_assets?() :: any() 54 | def watch_assets?, do: Application.get_env :kitto, :watch_assets?, true 55 | 56 | @doc """ 57 | Returns the binding ip of the assets watcher server 58 | """ 59 | @spec asset_server_host() :: any() 60 | def asset_server_host, do: Application.get_env :kitto, :assets_host, "127.0.0.1" 61 | 62 | @doc """ 63 | Returns the binding port of the assets watcher server 64 | """ 65 | @spec asset_server_port() :: any() 66 | def asset_server_port, do: Application.get_env :kitto, :assets_port, 8080 67 | 68 | defp ip, do: ip(Application.get_env(:kitto, :ip, @defaults.ip)) 69 | defp ip({:system, var}) do 70 | case System.get_env(var) do 71 | nil -> 72 | Logger.error "Configured binding ip via #{var} but no value is set" 73 | exit(:shutdown) 74 | address -> address 75 | |> String.split(".") 76 | |> Enum.map(&String.to_integer/1) 77 | |> List.to_tuple 78 | end 79 | end 80 | defp ip(address) when is_tuple(address), do: address 81 | defp ip(_), do: @defaults.ip 82 | defp ip_human(tup), do: tup |> Tuple.to_list |> Enum.join(".") 83 | 84 | defp port, do: port(Application.get_env(:kitto, :port)) 85 | defp port({:system, var}), do: var |> System.get_env |> Integer.parse |> elem(0) 86 | defp port(p) when is_integer(p), do: p 87 | defp port(_), do: @defaults.port 88 | 89 | 90 | defp children do 91 | case Kitto.CodeReloader.reload_code? do 92 | true -> children(:prod) ++ [worker(Kitto.CodeReloader, [[server: :runner]])] 93 | false -> children(:prod) 94 | end 95 | end 96 | 97 | defp children(:prod) do 98 | [supervisor(__MODULE__, [], function: :start_server), 99 | supervisor(Kitto.Notifier, []), 100 | worker(Kitto.BackoffServer, [[]]), 101 | worker(Kitto.StatsServer, [[]]), 102 | worker(Kitto.Runner, [[name: :runner]])] 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/kitto/stats_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.StatsServer do 2 | @moduledoc """ 3 | Module responsible for keeping stats about jobs. 4 | """ 5 | 6 | use GenServer 7 | 8 | @server __MODULE__ 9 | @default_stats %{ 10 | times_triggered: 0, 11 | times_completed: 0, 12 | failures: 0, 13 | avg_time_took: 0.0, 14 | total_running_time: 0.0 15 | } 16 | 17 | @doc false 18 | def start_link(opts) do 19 | GenServer.start_link(@server, opts, name: opts[:name] || @server) 20 | end 21 | 22 | @doc false 23 | def init(_), do: {:ok, %{}} 24 | 25 | @doc """ 26 | Executes the given function and keeps stats about it in the provided key 27 | """ 28 | @spec measure(map()) :: :ok 29 | def measure(job), do: measure(@server, job) 30 | def measure(server, job) do 31 | server |> initialize_stats(job.name) 32 | server |> update_trigger_count(job.name) 33 | server |> measure_call(job) 34 | end 35 | 36 | @doc """ 37 | Returns the current stats 38 | """ 39 | @spec stats() :: map() 40 | @spec stats(pid() | atom()) :: map() 41 | def stats, do: stats(@server) 42 | def stats(server), do: GenServer.call(server, :stats) 43 | 44 | @doc """ 45 | Resets the current stats 46 | """ 47 | @spec reset() :: :ok 48 | @spec reset(pid() | atom()) :: :ok 49 | def reset, do: reset(@server) 50 | def reset(server), do: GenServer.cast(server, :reset) 51 | 52 | ### Callbacks 53 | 54 | def handle_call(:stats, _from, state), do: {:reply, state, state} 55 | def handle_call({:initialize_stats, name}, _from, state) do 56 | {:reply, name, Map.merge(state, %{name => Map.get(state, name, @default_stats)})} 57 | end 58 | def handle_call({:update_trigger_count, name}, _from, state) do 59 | old_stats = state[name] 60 | new_stats = %{name => %{old_stats | times_triggered: old_stats[:times_triggered] + 1}} 61 | 62 | {:reply, name, Map.merge(state, new_stats)} 63 | end 64 | 65 | def handle_cast(:reset, _state), do: {:noreply, %{}} 66 | def handle_cast({:measure_call, job, run}, state) do 67 | current_stats = state[job.name] 68 | 69 | new_stats = case run do 70 | {:ok, time_took} -> 71 | backoff_module().succeed(job.name) 72 | times_completed = current_stats[:times_completed] + 1 73 | total_running_time = current_stats[:total_running_time] + time_took 74 | 75 | %{current_stats | 76 | times_completed: times_completed, 77 | total_running_time: total_running_time 78 | } |> Map.merge(%{avg_time_took: total_running_time / times_completed}) 79 | {:error, _} -> 80 | backoff_module().fail(job.name) 81 | %{current_stats | failures: current_stats[:failures] + 1} 82 | end 83 | 84 | {:noreply, Map.merge(state, %{job.name => new_stats})} 85 | end 86 | 87 | defp initialize_stats(server, name), do: GenServer.call(server, {:initialize_stats, name}) 88 | 89 | defp update_trigger_count(server, name), 90 | do: GenServer.call(server, {:update_trigger_count, name}) 91 | defp measure_call(server, job) do 92 | if backoff_enabled?(), do: backoff_module().backoff!(job.name) 93 | 94 | run = timed_call(job.job) 95 | 96 | GenServer.cast(server, {:measure_call, job, run}) 97 | 98 | if elem(run, 0) == :error do 99 | raise Kitto.Job.Error, %{exception: elem(run, 1), job: job} 100 | end 101 | end 102 | 103 | defp timed_call(f) do 104 | try do 105 | {:ok, ((f |> :timer.tc |> elem(0)) / 1_000_000)} 106 | rescue 107 | e -> {:error, e} 108 | end 109 | end 110 | 111 | defp backoff_enabled?, do: Application.get_env :kitto, :job_backoff_enabled?, true 112 | 113 | defp backoff_module do 114 | Application.get_env :kitto, :backoff_module, Kitto.BackoffServer 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/kitto/notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Notifier do 2 | @moduledoc """ 3 | Module responsible for broadcasting events across connections. 4 | """ 5 | 6 | use Supervisor 7 | 8 | import Agent, only: [start_link: 2, update: 2, get: 2] 9 | 10 | @doc """ 11 | Starts the notifier supervision tree 12 | """ 13 | def start_link, do: Supervisor.start_link(__MODULE__, :ok, name: :notifier_sup) 14 | 15 | @doc false 16 | def init(:ok) do 17 | children = [ 18 | worker(__MODULE__, [], function: :start_connections_cache, id: make_ref()), 19 | worker(__MODULE__, [], function: :start_notifier_cache, id: make_ref()) 20 | ] 21 | 22 | supervise(children, strategy: :one_for_one) 23 | end 24 | 25 | @doc """ 26 | Starts the connections cache agent 27 | """ 28 | def start_connections_cache, do: start_link(fn -> [] end, name: :notifier_connections) 29 | 30 | @doc """ 31 | Starts the notifier cache agent 32 | """ 33 | def start_notifier_cache, do: start_link(fn -> %{} end, name: :notifier_cache) 34 | 35 | @doc """ 36 | Every new SSE connection gets all the cached payloads for each job. 37 | The last broadcasted payload of each job is cached 38 | """ 39 | @spec initial_broadcast!(pid()) :: list() 40 | def initial_broadcast!(pid) do 41 | cache() |> Enum.each(fn ({topic, data}) -> broadcast!(pid, topic, data) end) 42 | end 43 | 44 | @doc """ 45 | Emits a server-sent event to each of the active connections with the given 46 | topic and payload 47 | """ 48 | @spec broadcast!(atom() | String.t(), atom() | map() | list()) :: list() 49 | def broadcast!(data, topic) when is_atom(topic), do: broadcast!(topic, data) 50 | def broadcast!(topic, data) do 51 | unless topic == "_kitto", do: cache(topic, data) 52 | 53 | connections() |> Enum.each(fn (connection) -> broadcast!(connection, topic, data) end) 54 | end 55 | 56 | @doc """ 57 | Emits a server-sent event to each of the active connections with the given 58 | topic and payload to a specific process 59 | """ 60 | @spec broadcast!(pid(), atom() | String.t(), map() | list()) :: list() 61 | def broadcast!(pid, topic, data) when is_atom(topic), do: broadcast!(pid, topic |> to_string, data) 62 | def broadcast!(pid, topic, data) do 63 | if !Process.alive?(pid), do: delete(pid) 64 | 65 | send pid, {:broadcast, {topic, data |> Map.merge(updated_at())}} 66 | end 67 | 68 | @doc """ 69 | Updates the list of connections to use for broadcasting 70 | """ 71 | @spec register(Conn.t()) :: Conn.t() 72 | def register(conn) do 73 | notifier_connections() |> update(&(&1 ++ [conn])) 74 | 75 | conn 76 | end 77 | 78 | @doc """ 79 | Returns cached broadcasts 80 | """ 81 | @spec cache() :: map() 82 | def cache, do: notifier_cache() |> get(&(&1)) 83 | 84 | @doc """ 85 | Resets the broadcast cache 86 | """ 87 | @spec clear_cache() :: :ok 88 | def clear_cache, do: notifier_cache() |> update(fn (_) -> %{} end) 89 | 90 | @doc """ 91 | Caches the given payload with the key provided as the first argument 92 | """ 93 | def cache(topic, data) when is_atom(topic), do: cache(topic |> to_string, data) 94 | def cache(topic, data), do: notifier_cache() |> update(&(Map.merge(&1, %{topic => data}))) 95 | 96 | @doc """ 97 | Removes a connection from the connections list 98 | """ 99 | @spec delete(Conn.t()) :: :ok 100 | def delete(conn), do: notifier_connections() |> update(&(&1 |> List.delete(conn))) 101 | 102 | @doc """ 103 | Returns the registered connections 104 | """ 105 | @spec connections() :: [Conn.t()] 106 | def connections, do: notifier_connections() |> get(&(&1)) 107 | 108 | defp notifier_connections, do: Process.whereis(:notifier_connections) 109 | defp notifier_cache, do: Process.whereis(:notifier_cache) 110 | defp updated_at, do: %{updated_at: :os.system_time(:seconds)} 111 | end 112 | -------------------------------------------------------------------------------- /lib/mix/tasks/kitto.install.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.Install do 2 | use Mix.Task 3 | @shortdoc "Install community Widget/Job from a Github Gist" 4 | 5 | @github_base_url "https://api.github.com/gists/" 6 | @supported_languages ["JavaScript", "SCSS", "Markdown", "Elixir"] 7 | 8 | @job_rexp ~r/\.exs$/ 9 | @lib_rexp ~r/\.ex$/ 10 | 11 | @moduledoc """ 12 | Installs community Widget/Job from a Github Gist 13 | 14 | mix kitto.install --widget test_widget --gist JanStevens/0209a4a80cee782e5cdbe11a1e9bc393 15 | mix kitto.install --gist 0209a4a80cee782e5cdbe11a1e9bc393 16 | 17 | ## Options 18 | 19 | * `--widget` - specifies the widget name that will be used as directory name 20 | in the widgets directory. By default we use the js filename as directory 21 | 22 | * `--gist` - The gist to download from, specified as `Username/Gist` or `Gist` 23 | 24 | """ 25 | def run(args) do 26 | {:ok, _started} = Application.ensure_all_started(:httpoison) 27 | {opts, _parsed, _} = OptionParser.parse(args, strict: [widget: :string, gist: :string]) 28 | opts_map = Enum.into(opts, %{}) 29 | 30 | process(opts_map) 31 | end 32 | 33 | defp process(%{gist: gist, widget: widget}) do 34 | files = gist |> String.split("/") 35 | |> build_gist_url 36 | |> download_gist 37 | |> Map.get(:files) 38 | |> Enum.map(&extract_file_properties/1) 39 | |> Enum.filter(&supported_file_type?/1) 40 | 41 | widget_dir = widget || find_widget_filename(files) 42 | 43 | files 44 | |> Enum.map(&(determine_file_location(&1, widget_dir))) 45 | |> Enum.each(&write_file/1) 46 | end 47 | 48 | defp process(%{gist: gist}), do: process(%{gist: gist, widget: nil}) 49 | 50 | defp process(_) do 51 | Mix.shell.error "Unsupported arguments" 52 | end 53 | 54 | defp write_file(file) do 55 | Mix.Generator.create_directory(file.path) 56 | Mix.Generator.create_file(Path.join(file.path, file.filename), file.content) 57 | end 58 | 59 | defp determine_file_location(_file, widget_name) when is_nil(widget_name) do 60 | Mix.shell.error "Please specify a widget directory using the --widget flag" 61 | Mix.raise "Installation failed" 62 | end 63 | 64 | defp determine_file_location(file = %{language: "Elixir", filename: filename}, _) do 65 | file |> put_in([:path], (cond do 66 | Regex.match?(@job_rexp, filename) -> "jobs" 67 | Regex.match?(@lib_rexp, filename) -> "lib" 68 | true -> Mix.shell.error "Found Elixir file #{filename} not ending in .ex or exs" 69 | end)) 70 | end 71 | 72 | # Other files all go into the widgets dir 73 | defp determine_file_location(file, widget_name) do 74 | put_in file, [:path], Path.join(["widgets", widget_name]) 75 | end 76 | 77 | defp find_widget_filename(files) do 78 | files 79 | |> Enum.filter(&(&1.language == "JavaScript")) 80 | |> List.first 81 | |> extract_widget_dir 82 | end 83 | 84 | defp extract_widget_dir(%{filename: filename}) do 85 | filename |> String.replace(~r/\.js$/, "") 86 | end 87 | 88 | defp extract_widget_dir(nil), do: nil 89 | 90 | defp supported_file_type?(file), do: Enum.member?(@supported_languages, file.language) 91 | 92 | defp extract_file_properties({_filename, file}), do: file 93 | 94 | defp download_gist(url), do: url |> HTTPoison.get! |> process_response 95 | 96 | defp build_gist_url([gist_url]), do: @github_base_url <> gist_url 97 | defp build_gist_url([_ | gist_url]), do: build_gist_url(gist_url) 98 | 99 | defp process_response(%HTTPoison.Response{status_code: 200, body: body}) do 100 | body |> Poison.decode!(keys: :atoms) 101 | end 102 | 103 | defp process_response(%HTTPoison.Response{status_code: code, body: body}) do 104 | decoded_body = body |> Poison.decode!(keys: :atoms) 105 | 106 | Mix.shell.error "Could not fetch the gist from GitHub: " <> 107 | "#{code}: #{decoded_body.message}" 108 | Mix.raise "Installation failed" 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /installer/templates/new/webpack.config.js: -------------------------------------------------------------------------------- 1 | const glob = require('glob'); 2 | const path = require('path'); 3 | const merge = require('webpack-merge'); 4 | const webpack = require('webpack'); 5 | 6 | const TARGET = process.env.npm_lifecycle_event; 7 | const PATHS = { 8 | app: path.join(__dirname, 'assets/javascripts/application.js'), 9 | widgets: glob.sync('./widgets/**/*.js'), 10 | build: path.join(__dirname, 'priv/static'), 11 | gridster: path.join(__dirname, 'node_modules/gridster/dist'), 12 | d3: path.join(__dirname, 'node_modules/d3/d3.min.js'), 13 | rickshaw: path.join(__dirname, 'node_modules/rickshaw/rickshaw.js') 14 | }; 15 | 16 | process.env.BABEL_ENV = TARGET; 17 | 18 | const common = { 19 | entry: { 20 | application: PATHS.app, 21 | widgets: PATHS.widgets 22 | }, 23 | resolve: { 24 | extensions: ['.js', '.jsx', 'css', 'scss'], 25 | modules: [ 26 | 'node_modules', 27 | PATHS.gridster 28 | ], 29 | alias: { 30 | d3: PATHS.d3 31 | } 32 | }, 33 | output: { 34 | path: PATHS.build, 35 | publicPath: '/assets/', 36 | filename: '[name].js' 37 | }, 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.css$/, 42 | use: [ 43 | 'style-loader', 44 | 'css-loader' 45 | ] 46 | }, 47 | { 48 | test: /\.scss$/, 49 | use: [ 50 | 'style-loader', 51 | 'css-loader', 52 | 'sass-loader' 53 | ] 54 | }, 55 | { 56 | test: /\.jsx?$/, 57 | use: [ 58 | { 59 | loader: 'babel-loader', 60 | options: { 61 | cacheDirectory: true 62 | } 63 | } 64 | ] 65 | }, 66 | { 67 | test: /\.(svg|png|jpe?g|gif)(\?\S*)?$/, 68 | use: [ 69 | { 70 | loader: 'file-loader', 71 | options: { 72 | name: 'images/[name].[ext]' 73 | } 74 | } 75 | ] 76 | }, 77 | { 78 | test: /\.(eot|woff|woff2|ttf)(\?\S*)?$/, 79 | loader: 'file-loader', 80 | options: { 81 | outputPath: 'fonts' 82 | } 83 | }, 84 | { 85 | test: require.resolve('jquery-knob'), 86 | use: 'imports-loader?require=>false,define=>false,this=>window' 87 | }, 88 | { 89 | test: PATHS.d3, 90 | use: ['script-loader'] 91 | }, 92 | { 93 | test: require.resolve('rickshaw'), 94 | use: ['script-loader'] 95 | } 96 | ] 97 | } 98 | }; 99 | 100 | // Development Environment 101 | if (TARGET === 'start' || !TARGET) { 102 | module.exports = merge(common, { 103 | devtool: 'eval-source-map', 104 | devServer: { 105 | contentBase: PATHS.build, 106 | headers: { 'Access-Control-Allow-Origin': '*' }, 107 | historyApiFallback: true, 108 | hot: true, 109 | inline: true, 110 | progress: true, 111 | publicPath: '/assets/', 112 | 113 | // display only errors to reduce the amount of output 114 | stats: 'errors-only', 115 | 116 | // Binding address of webpack-dev-server 117 | // Read more: https://github.com/kittoframework/kitto/wiki/Customize-Asset-Watcher 118 | host: process.env.KITTO_ASSETS_HOST, 119 | port: process.env.KITTO_ASSETS_PORT 120 | }, 121 | plugins: [new webpack.HotModuleReplacementPlugin()] 122 | }); 123 | } 124 | 125 | // Production Environment 126 | if (TARGET === 'build') { 127 | var CompressionPlugin = require("compression-webpack-plugin"); 128 | 129 | module.exports = merge(common, { 130 | plugins: [ 131 | new webpack.optimize.UglifyJsPlugin({ 132 | compress: { 133 | warnings: false, 134 | keep_fnames: true 135 | }, 136 | mangle: { 137 | keep_fnames: true 138 | } 139 | }), 140 | new CompressionPlugin({ 141 | filename: '[path].gz[query]', 142 | algorithm: 'gzip', 143 | test: /\.js$|\.html$/, 144 | compressionOptions: { 145 | verbose: true 146 | } 147 | }) 148 | ] 149 | }); 150 | } 151 | -------------------------------------------------------------------------------- /lib/kitto/runner.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Runner do 2 | @moduledoc """ 3 | Module responsible for loading job files 4 | """ 5 | 6 | use GenServer 7 | 8 | require Logger 9 | alias Kitto.Job.{Validator, Workspace} 10 | 11 | @doc """ 12 | Starts the runner supervision tree 13 | """ 14 | def start_link(opts) do 15 | GenServer.start_link(__MODULE__, opts, name: opts[:name] || __MODULE__) 16 | end 17 | 18 | @doc false 19 | def init(opts) do 20 | server = self() 21 | spawn fn -> load_jobs(server) end 22 | 23 | {:ok, %{opts: opts, jobs: [], supervisor: nil}} 24 | end 25 | 26 | @doc """ 27 | Updates the list of jobs to be run with the provided one 28 | """ 29 | @spec register(pid() | atom(), map()) :: map() 30 | def register(server, job) do 31 | GenServer.call(server, {:register, job}) 32 | end 33 | 34 | @doc """ 35 | Reloads all jobs defined in the given file 36 | """ 37 | @spec register(pid() | atom(), map()) :: :ok 38 | def reload_job(server, file) do 39 | GenServer.cast(server, {:reload_job, file}) 40 | end 41 | 42 | @doc """ 43 | Stops all jobs defined in the given file 44 | """ 45 | @spec stop_job(pid() | atom(), String.t()) :: :ok 46 | def stop_job(server, file) do 47 | GenServer.cast(server, {:stop_job, file}) 48 | end 49 | 50 | @doc """ 51 | Returns all the registered jobs 52 | """ 53 | @spec jobs(pid() | atom()) :: list(map()) 54 | def jobs(server) do 55 | GenServer.call(server, {:jobs}) 56 | end 57 | 58 | @doc """ 59 | Returns the directory where the job scripts are located 60 | """ 61 | @spec jobs_dir() :: String.t() 62 | def jobs_dir, do: Path.join(Kitto.root, Application.get_env(:kitto, :jobs_dir, "jobs")) 63 | 64 | ### Callbacks 65 | 66 | def handle_call({:jobs}, _from, state) do 67 | {:reply, state.jobs, state} 68 | end 69 | 70 | def handle_call({:register, job}, _from, state) do 71 | {:reply, job, %{state | jobs: state.jobs ++ [job]}} 72 | end 73 | 74 | @doc false 75 | def handle_cast({:jobs_loaded}, state) do 76 | supervisor_opts = %{name: state.opts[:supervisor_name] || :runner_supervisor, 77 | jobs: state.jobs} 78 | 79 | {:ok, supervisor} = start_supervisor(supervisor_opts) 80 | 81 | {:noreply, %{state | supervisor: supervisor}} 82 | end 83 | 84 | def handle_cast({:reload_job, file}, state) do 85 | Logger.info "Reloading job file: #{file}" 86 | 87 | jobs = stop_jobs(state, file) 88 | 89 | server = self() 90 | spawn fn -> 91 | load_job(server, file) 92 | server 93 | |> jobs 94 | |> jobs_in_file(file) 95 | |> Enum.each(&(start_job(state.supervisor, &1))) 96 | end 97 | 98 | {:noreply, %{state | jobs: jobs}} 99 | end 100 | 101 | def handle_cast({:stop_job, file}, state) do 102 | Logger.info "Stopping jobs in file: #{file}" 103 | 104 | {:noreply, %{state | jobs: stop_jobs(state, file)}} 105 | end 106 | 107 | defp jobs_in_file(jobs, file) do 108 | jobs |> Enum.filter(fn %{definition: %{file: f}} -> f == file end) 109 | end 110 | 111 | defp start_supervisor(opts) do 112 | Kitto.Runner.JobSupervisor.start_link(opts) 113 | end 114 | 115 | defp start_job(supervisor, job) do 116 | Kitto.Runner.JobSupervisor.start_job(supervisor, job) 117 | end 118 | 119 | defp load_job(pid, file) do 120 | case file |> Validator.valid? do 121 | true -> file |> Workspace.load_file(pid) 122 | false -> Logger.warn "Job: #{file} contains syntax error(s) and will not be loaded" 123 | end 124 | end 125 | 126 | defp stop_jobs(state, file) do 127 | state.jobs 128 | |> jobs_in_file(file) 129 | |> Enum.reduce(state.jobs, fn (job, jobs) -> 130 | Supervisor.terminate_child(state.supervisor, job.name) 131 | Supervisor.delete_child(state.supervisor, job.name) 132 | jobs |> List.delete(job) 133 | end) 134 | end 135 | 136 | defp load_jobs(pid) do 137 | job_files() |> Enum.each(&(load_job(pid, &1))) 138 | 139 | GenServer.cast pid, {:jobs_loaded} 140 | end 141 | 142 | defp job_files, do: Path.wildcard(Path.join(jobs_dir(), "/**/*.{ex,exs}")) 143 | end 144 | -------------------------------------------------------------------------------- /test/mix/tasks/kitto.install_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.InstallTest do 2 | use ExUnit.Case, async: false 3 | import Mock 4 | import Kitto.FileAssertionHelper 5 | 6 | @css_gist_response %{ 7 | files: %{"number.scss" => %{filename: "number.scss", 8 | language: "SCSS", 9 | content: "style"}}} 10 | 11 | @gist_response %{ 12 | files: %{ 13 | "README.md" => %{filename: "README.md", language: "Markdown", content: "Title"}, 14 | "number.ex" => %{filename: "number.ex", language: "Elixir", content: "lib"}, 15 | "number.exs" => %{filename: "number.exs", language: "Elixir", content: "job"}, 16 | "number.scss" => %{filename: "number.scss", language: "SCSS", content: "style"}, 17 | "number.js" => %{filename: "number.js", language: "JavaScript", content: "js"} 18 | } 19 | } 20 | 21 | setup do 22 | Mix.Task.clear 23 | :ok 24 | end 25 | 26 | test "fails when `--gist` is not provided" do 27 | Mix.Tasks.Kitto.Install.run(["--widget", "numbers"]) 28 | 29 | assert_received {:mix_shell, :error, ["Unsupported arguments"]} 30 | end 31 | 32 | test "fails when the gist is not found" do 33 | with_mock HTTPoison, [get!: mock_gist_with(404, %{message: "Not Found"})] do 34 | 35 | assert_raise Mix.Error, fn -> 36 | Mix.Tasks.Kitto.Install.run(["--widget", "numbers", "--gist", "0209a4a80cee78"]) 37 | 38 | assert called HTTPoison.get!("https://api.github.com/gists/0209a4a80cee78") 39 | end 40 | 41 | assert_received {:mix_shell, :error, ["Could not fetch the gist from GitHub: 404: Not Found"]} 42 | end 43 | end 44 | 45 | test "fails when no widget directory is specified or found" do 46 | with_mock HTTPoison, [get!: mock_gist_with(200, @css_gist_response)] do 47 | 48 | assert_raise Mix.Error, fn -> 49 | Mix.Tasks.Kitto.Install.run(["--gist", "0209a4a80cee78"]) 50 | 51 | assert called HTTPoison.get!("https://api.github.com/gists/0209a4a80cee78") 52 | end 53 | end 54 | 55 | assert_received {:mix_shell, :error, ["Please specify a widget directory using the --widget flag"]} 56 | end 57 | 58 | test "places all the files in the correct locations" do 59 | in_tmp "installs widgets and jobs", fn -> 60 | with_mock HTTPoison, [get!: mock_gist_with(200, @gist_response)] do 61 | Mix.Tasks.Kitto.Install.run(["--gist", "0209a4a80cee78"]) 62 | 63 | assert_file "widgets/number/number.js", fn contents -> 64 | assert contents =~ "js" 65 | end 66 | 67 | assert_file "widgets/number/number.scss", fn contents -> 68 | assert contents =~ "style" 69 | end 70 | 71 | assert_file "widgets/number/README.md", fn contents -> 72 | assert contents =~ "Title" 73 | end 74 | refute_file "widgets/number/number.exs" 75 | 76 | assert_file "lib/number.ex", fn contents -> 77 | assert contents =~ "lib" 78 | end 79 | 80 | assert_file "jobs/number.exs", fn contents -> 81 | assert contents =~ "job" 82 | end 83 | end 84 | end 85 | end 86 | 87 | test "uses the widget overwrite for the widget directory" do 88 | in_tmp "installs widgets and jobs using overwrite", fn -> 89 | with_mock HTTPoison, [get!: mock_gist_with(200, @gist_response)] do 90 | Mix.Tasks.Kitto.Install.run(["--gist", "0209a4a80cee78", "--widget", "overwrite"]) 91 | 92 | assert_file "widgets/overwrite/number.js", fn contents -> 93 | assert contents =~ "js" 94 | end 95 | 96 | assert_file "widgets/overwrite/number.scss", fn contents -> 97 | assert contents =~ "style" 98 | end 99 | 100 | assert_file "widgets/overwrite/README.md", fn contents -> 101 | assert contents =~ "Title" 102 | end 103 | refute_file "widgets/overwrite/number.exs" 104 | 105 | assert_file "jobs/number.exs", fn contents -> 106 | assert contents =~ "job" 107 | end 108 | end 109 | end 110 | end 111 | 112 | def mock_gist_with(status_code, body) do 113 | fn (_url) -> 114 | %HTTPoison.Response{status_code: status_code, body: Poison.encode!(body)} 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/code_reloader_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.CodeReloaderTest do 2 | use ExUnit.Case 3 | 4 | import Mock 5 | 6 | alias Kitto.CodeReloader 7 | 8 | @jobs_dir "test/fixtures/jobs" 9 | @valid_job Path.join(@jobs_dir, "valid_job.exs") |> Path.absname 10 | @lib_file Path.join("lib", "travis.ex") |> Path.absname 11 | 12 | setup do 13 | Application.put_env :kitto, :jobs_dir, @jobs_dir 14 | 15 | on_exit fn -> 16 | Application.delete_env :kitto, :jobs_dir 17 | Application.delete_env :kitto, :reload_code? 18 | end 19 | end 20 | 21 | test "#reload_code? returns true when :reload_code? env is not set" do 22 | Application.delete_env :kitto, :reload_code? 23 | 24 | assert CodeReloader.reload_code? == true 25 | end 26 | 27 | test "#reload_code? returns true when :reload_code? env is true" do 28 | Application.put_env :kitto, :reload_code?, true 29 | 30 | assert CodeReloader.reload_code? == true 31 | end 32 | 33 | test "#reload_code? returns false when :reload_code? env is false" do 34 | Application.put_env :kitto, :reload_code?, false 35 | 36 | assert CodeReloader.reload_code? == false 37 | end 38 | 39 | test "#when a job modification event is received on linux, calls Runner.reload_job/1" do 40 | self() |> Process.register(:mock_server) 41 | 42 | {:ok, reloader} = CodeReloader.start_link(name: :reloader, server: :mock_server) 43 | 44 | send reloader, {make_ref(), {:fs, :file_event}, {@valid_job, [:modified, :closed]}} 45 | 46 | receive do 47 | message -> assert message == {:"$gen_cast", {:reload_job, @valid_job}} 48 | after 49 | 100 -> exit({:shutdown, "runner did not receive reload message"}) 50 | end 51 | end 52 | 53 | test "#when a job creation event is received on linux, calls Runner.reload_job/1" do 54 | self() |> Process.register(:mock_server) 55 | 56 | {:ok, reloader} = CodeReloader.start_link(name: :reloader, server: :mock_server) 57 | 58 | send reloader, {make_ref(), {:fs, :file_event}, {@valid_job, [:created]}} 59 | 60 | receive do 61 | message -> assert message == {:"$gen_cast", {:reload_job, @valid_job}} 62 | after 63 | 100 -> exit({:shutdown, "runner did not receive reload message"}) 64 | end 65 | end 66 | 67 | test "#when a job deletion event is received on linux, calls Runner.stop_job/1" do 68 | self() |> Process.register(:mock_server) 69 | 70 | {:ok, reloader} = CodeReloader.start_link(name: :reloader, server: :mock_server) 71 | 72 | send reloader, {make_ref(), {:fs, :file_event}, {@valid_job, [:deleted]}} 73 | 74 | receive do 75 | message -> assert message == {:"$gen_cast", {:stop_job, @valid_job}} 76 | after 77 | 100 -> exit({:shutdown, "runner did not receive stop message"}) 78 | end 79 | end 80 | 81 | describe "macOS job modifications events" do 82 | test "#when [:inodemetamod, :modified] is received, calls Runner.reload_job/1" do 83 | self() |> Process.register(:mock_server) 84 | 85 | {:ok, reloader} = CodeReloader.start_link(name: :reloader, server: :mock_server) 86 | 87 | file_change = {@valid_job, [:inodemetamod, :modified]} 88 | 89 | send reloader, {make_ref(), {:fs, :file_event}, file_change} 90 | 91 | receive do 92 | message -> assert message == {:"$gen_cast", {:reload_job, @valid_job}} 93 | after 94 | 100 -> exit({:shutdown, "runner did not receive reload message"}) 95 | end 96 | end 97 | 98 | test """ 99 | #when [:created, :renamed, :modified, :changeowner] is received, 100 | #calls Runner.reload_job/1 101 | """ do 102 | self() |> Process.register(:mock_server) 103 | 104 | {:ok, reloader} = CodeReloader.start_link(name: :reloader, server: :mock_server) 105 | 106 | file_change = {@valid_job, [:inodemetamod, :modified]} 107 | 108 | send reloader, {make_ref(), {:fs, :file_event}, file_change} 109 | 110 | receive do 111 | message -> assert message == {:"$gen_cast", {:reload_job, @valid_job}} 112 | after 113 | 100 -> exit({:shutdown, "runner did not receive reload message"}) 114 | end 115 | end 116 | end 117 | 118 | test "#when a lib modification file event is received, calls elixir compilation task" do 119 | test_pid = self() 120 | mock_run = fn (_) -> send test_pid, :compiled end 121 | 122 | with_mock Mix.Tasks.Compile.Elixir, [run: mock_run] do 123 | {:ok, reloader} = CodeReloader.start_link(name: :reloader, server: :mock_server) 124 | 125 | send reloader, {make_ref(), {:fs, :file_event}, {@lib_file, [:modified, :closed]}} 126 | 127 | receive do 128 | :compiled -> :ok 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 2 | "certifi": {:hex, :certifi, "1.2.1", "c3904f192bd5284e5b13f20db3ceac9626e14eeacfbb492e19583cf0e37b22be", [:rebar3], [], "hexpm"}, 3 | "combine": {:hex, :combine, "0.7.0"}, 4 | "cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:make, :rebar], [{:cowlib, "~> 1.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, 6 | "credo": {:hex, :credo, "0.9.0", "5d1b494e4f2dc672b8318e027bd833dda69be71eaac6eedd994678be74ef7cb4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 8 | "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm"}, 9 | "ex_doc": {:hex, :ex_doc, "0.17.1", "39f777415e769992e6732d9589dc5846ea587f01412241f4a774664c746affbb", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "excoveralls": {:hex, :excoveralls, "0.7.4", "3d84b2f15a0e593159f74b19f83794b464b34817183d27965bdc6c462de014f9", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "fs": {:hex, :fs, "2.12.0", "ad631efacc9a5683c8eaa1b274e24fa64a1b8eb30747e9595b93bec7e492e25e", [:rebar3], [], "hexpm"}, 13 | "gettext": {:hex, :gettext, "0.11.0"}, 14 | "hackney": {:hex, :hackney, "1.8.6", "21a725db3569b3fb11a6af17d5c5f654052ce9624219f1317e8639183de4a423", [:rebar3], [{:certifi, "1.2.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.0.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 15 | "httpoison": {:hex, :httpoison, "0.11.2", "9e59f17a473ef6948f63c51db07320477bad8ba88cf1df60a3eee01150306665", [:mix], [{:hackney, "~> 1.8.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "idna": {:hex, :idna, "5.0.2", "ac203208ada855d95dc591a764b6e87259cb0e2a364218f215ad662daa8cd6b4", [:rebar3], [{:unicode_util_compat, "0.2.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 18 | "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], [], "hexpm"}, 19 | "meck": {:hex, :meck, "0.8.8", "eeb3efe811d4346e1a7f65b2738abc2ad73cbe1a2c91b5dd909bac2ea0414fa6", [:rebar3], [], "hexpm"}, 20 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 21 | "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], [], "hexpm"}, 22 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 23 | "mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, 24 | "plug": {:hex, :plug, "1.3.5", "7503bfcd7091df2a9761ef8cecea666d1f2cc454cbbaf0afa0b6e259203b7031", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, 25 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 26 | "ranch": {:hex, :ranch, "1.4.0", "10272f95da79340fa7e8774ba7930b901713d272905d0012b06ca6d994f8826b", [:rebar3], [], "hexpm"}, 27 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 28 | "timex": {:hex, :timex, "2.1.4"}, 29 | "tzdata": {:hex, :tzdata, "0.5.7"}, 30 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.2.0", "dbbccf6781821b1c0701845eaf966c9b6d83d7c3bfc65ca2b78b88b8678bfa35", [:rebar3], [], "hexpm"}} 31 | -------------------------------------------------------------------------------- /installer/templates/new/assets/stylesheets/application.scss: -------------------------------------------------------------------------------- 1 | @import "~jquery.gridster.css"; 2 | @import '~font-awesome/css/font-awesome.css'; 3 | 4 | // ---------------------------------------------------------------------------- 5 | // Sass declarations 6 | // ---------------------------------------------------------------------------- 7 | $background-color: #222; 8 | $text-color: #fff; 9 | 10 | $background-warning-color-1: #eeae32; 11 | $background-warning-color-2: #ff9618; 12 | $text-warning-color: #fff; 13 | 14 | $background-danger-color-1: #e82711; 15 | $background-danger-color-2: #9b2d23; 16 | $text-danger-color: #fff; 17 | 18 | @-webkit-keyframes status-warning-background { 19 | 0% { background-color: $background-warning-color-1; } 20 | 50% { background-color: $background-warning-color-2; } 21 | 100% { background-color: $background-warning-color-1; } 22 | } 23 | @-webkit-keyframes status-danger-background { 24 | 0% { background-color: $background-danger-color-1; } 25 | 50% { background-color: $background-danger-color-2; } 26 | 100% { background-color: $background-danger-color-1; } 27 | } 28 | @mixin animation($animation-name, $duration, $function, $animation-iteration-count:""){ 29 | -webkit-animation: $animation-name $duration $function #{$animation-iteration-count}; 30 | -moz-animation: $animation-name $duration $function #{$animation-iteration-count}; 31 | -ms-animation: $animation-name $duration $function #{$animation-iteration-count}; 32 | } 33 | 34 | // ---------------------------------------------------------------------------- 35 | // Base styles 36 | // ---------------------------------------------------------------------------- 37 | html { 38 | font-size: 100%; 39 | -webkit-text-size-adjust: 100%; 40 | -ms-text-size-adjust: 100%; 41 | } 42 | 43 | body { 44 | margin: 0; 45 | background-color: $background-color; 46 | font-size: 20px; 47 | color: $text-color; 48 | font-family: 'Open Sans', "Helvetica Neue", Helvetica, Arial, sans-serif; 49 | } 50 | 51 | b, strong { 52 | font-weight: bold; 53 | } 54 | 55 | a { 56 | text-decoration: none; 57 | color: inherit; 58 | } 59 | 60 | img { 61 | border: 0; 62 | -ms-interpolation-mode: bicubic; 63 | vertical-align: middle; 64 | } 65 | 66 | img, object { 67 | max-width: 100%; 68 | } 69 | 70 | iframe { 71 | max-width: 100%; 72 | } 73 | 74 | table { 75 | border-collapse: collapse; 76 | border-spacing: 0; 77 | width: 100%; 78 | } 79 | 80 | td { 81 | vertical-align: middle; 82 | } 83 | 84 | ul, ol { 85 | padding: 0; 86 | margin: 0; 87 | } 88 | 89 | h1, h2, h3, h4, h5, p { 90 | padding: 0; 91 | margin: 0; 92 | } 93 | h1 { 94 | margin-bottom: 12px; 95 | text-align: center; 96 | font-size: 30px; 97 | font-weight: 400; 98 | } 99 | h2 { 100 | text-transform: uppercase; 101 | font-size: 76px; 102 | font-weight: 700; 103 | color: $text-color; 104 | } 105 | h3 { 106 | font-size: 25px; 107 | font-weight: 600; 108 | color: $text-color; 109 | } 110 | 111 | // ---------------------------------------------------------------------------- 112 | // Base widget styles 113 | // ---------------------------------------------------------------------------- 114 | .gridster { 115 | margin: 0px auto; 116 | } 117 | 118 | .icon-background { 119 | width: 100%!important; 120 | height: 100%; 121 | position: absolute; 122 | left: 0; 123 | top: 0; 124 | opacity: 0.1; 125 | font-size: 275px; 126 | text-align: center; 127 | margin-top: 82px; 128 | } 129 | 130 | .list-nostyle { 131 | list-style: none; 132 | } 133 | 134 | .gridster ul { 135 | list-style: none; 136 | } 137 | 138 | .gs-w { 139 | width: 100%; 140 | display: table; 141 | cursor: pointer; 142 | } 143 | 144 | .widget { 145 | display: table-cell; 146 | padding: 25px 12px; 147 | box-sizing: border-box; 148 | text-align: center; 149 | width: 100%; 150 | vertical-align: middle; 151 | } 152 | 153 | .widget.status-warning { 154 | background-color: $background-warning-color-1; 155 | @include animation(status-warning-background, 2s, ease, infinite); 156 | 157 | .icon-warning-sign { 158 | display: inline-block; 159 | } 160 | 161 | .title, .more-info { 162 | color: $text-warning-color; 163 | } 164 | } 165 | 166 | .widget.status-danger { 167 | color: $text-danger-color; 168 | background-color: $background-danger-color-1; 169 | @include animation(status-danger-background, 2s, ease, infinite); 170 | 171 | .icon-warning-sign { 172 | display: inline-block; 173 | } 174 | 175 | .title, .more-info { 176 | color: $text-danger-color; 177 | } 178 | } 179 | 180 | .more-info { 181 | font-size: 15px; 182 | position: absolute; 183 | bottom: 32px; 184 | left: 0; 185 | right: 0; 186 | } 187 | 188 | .updated-at { 189 | font-size: 15px; 190 | position: absolute; 191 | bottom: 12px; 192 | left: 0; 193 | right: 0; 194 | } 195 | 196 | #container { 197 | padding-top: 5px; 198 | } 199 | 200 | .fullscreen-button { 201 | cursor: pointer; 202 | position: fixed; 203 | right: 5px; 204 | top: 5px; 205 | opacity: 0; 206 | z-index: 10; 207 | transition: opacity 0.5s ease-out; 208 | 209 | &.active { 210 | opacity: 1; 211 | } 212 | } 213 | 214 | // ---------------------------------------------------------------------------- 215 | // Clearfix 216 | // ---------------------------------------------------------------------------- 217 | .clearfix:before, .clearfix:after { content: "\0020"; display: block; height: 0; overflow: hidden; } 218 | .clearfix:after { clear: both; } 219 | .clearfix { zoom: 1; } 220 | -------------------------------------------------------------------------------- /lib/kitto/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Kitto.Router do 2 | use Plug.Router 3 | 4 | alias Kitto.{View, Notifier} 5 | 6 | if Application.get_env(:kitto, :debug), do: use Plug.Debugger, otp_app: :kitto 7 | unless Mix.env == :test, do: plug Plug.Logger 8 | use Plug.ErrorHandler 9 | 10 | plug :match 11 | plug Kitto.Plugs.Authentication 12 | 13 | if Application.get_env(:kitto, :serve_assets?, true) do 14 | plug Plug.Static, 15 | at: "assets", 16 | gzip: true, 17 | from: Application.get_env(:kitto, :assets_path) || Application.get_env(:kitto, :otp_app) 18 | end 19 | 20 | plug :dispatch 21 | 22 | get "/", do: conn |> redirect_to_default_dashboard 23 | get "dashboards", do: conn |> redirect_to_default_dashboard 24 | 25 | get "dashboards/rotator" do 26 | conn = conn |> fetch_query_params 27 | query_params = conn.query_params 28 | dashboards = String.split(query_params["dashboards"], ",") 29 | interval = query_params["interval"] || 60 30 | 31 | if View.exists?("rotator") do 32 | conn |> render("rotator", [dashboards: dashboards, interval: interval]) 33 | else 34 | info = "Rotator template is missing. 35 | See: https://github.com/kittoframework/kitto/wiki/Cycling-Between-Dashboards 36 | for instructions to enable cycling between dashboards." 37 | 38 | send_resp(conn, 404, info) 39 | end 40 | end 41 | 42 | get "dashboards/*id" do 43 | path = Enum.join(id, "/") 44 | 45 | if View.exists?(path) do 46 | conn 47 | |> put_resp_header("content-type", "text/html") 48 | |> render(path) 49 | else 50 | render_error(conn, 404, "Dashboard does not exist") 51 | end 52 | end 53 | 54 | post "dashboards", private: %{authenticated: true} do 55 | {:ok, body, conn} = read_body conn 56 | command = body |> Poison.decode! |> Map.put_new("dashboard", "*") 57 | Notifier.broadcast! "_kitto", command 58 | 59 | conn |> send_resp(204, "") 60 | end 61 | 62 | post "dashboards/:id", private: %{authenticated: true} do 63 | {:ok, body, conn} = read_body conn 64 | command = body |> Poison.decode! |> Map.put("dashboard", id) 65 | Notifier.broadcast! "_kitto", command 66 | 67 | conn |> send_resp(204, "") 68 | end 69 | 70 | get "events" do 71 | conn = initialize_sse(conn) 72 | 73 | Notifier.register(conn.owner) 74 | conn = listen_sse(conn, subscribed_topics(conn)) 75 | 76 | conn 77 | end 78 | 79 | get "widgets", do: conn |> render_json(Notifier.cache) 80 | get "widgets/:id", do: conn |> render_json(Notifier.cache[id]) 81 | 82 | post "widgets/:id", private: %{authenticated: true} do 83 | {:ok, body, conn} = read_body(conn) 84 | 85 | Notifier.broadcast!(id, body |> Poison.decode!) 86 | 87 | conn |> send_resp(204, "") 88 | end 89 | 90 | get "assets/*asset" do 91 | if Kitto.watch_assets? do 92 | conn |> redirect_to("#{development_assets_url()}#{asset |> Enum.join("/")}") 93 | else 94 | conn |> render_error(404, "Not Found") |> halt 95 | end 96 | end 97 | 98 | defp initialize_sse(conn) do 99 | conn 100 | |> put_resp_header("content-type", "text/event-stream") 101 | |> put_resp_header("cache-control", "no-cache") 102 | |> put_resp_header("x-accel-buffering", "no") 103 | |> send_chunked(200) 104 | |> send_cached_events 105 | end 106 | 107 | match _, do: render_error(conn, 404, "Not Found") 108 | 109 | def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}), 110 | do: render_error(conn, 500, "Something went wrong") 111 | 112 | defp render(conn, template, bindings \\ []), 113 | do: send_resp(conn, 200, View.render(template, bindings)) 114 | 115 | defp render_error(conn, code, message), 116 | do: send_resp(conn, code, View.render_error(code, message)) 117 | 118 | defp listen_sse(conn, :""), do: listen_sse(conn, nil) 119 | defp listen_sse(conn, topics) do 120 | receive do 121 | {:broadcast, {topic, data}} -> 122 | res = case is_nil(topics) || to_string(topic) in topics do 123 | true -> send_event(conn, topic, data) 124 | false -> conn 125 | end 126 | 127 | case res do 128 | :closed -> conn |> halt 129 | _ -> res |> listen_sse(topics) 130 | end 131 | {:error, :closed} -> conn |> halt 132 | {:misc, :close} -> conn |> halt 133 | _ -> listen_sse(conn, topics) 134 | end 135 | end 136 | 137 | defp send_event(conn, topic, data) do 138 | {_, conn} = chunk(conn, (["event: #{topic}", 139 | "data: {\"message\": #{Poison.encode!(data)}}"] 140 | |> Enum.join("\n")) <> "\n\n") 141 | 142 | conn 143 | end 144 | 145 | defp send_cached_events(conn) do 146 | Notifier.initial_broadcast!(conn.owner) 147 | 148 | conn 149 | end 150 | 151 | defp redirect_to(conn, path) do 152 | conn 153 | |> put_resp_header("location", path) 154 | |> send_resp(301, "") 155 | |> halt 156 | end 157 | 158 | defp redirect_to_default_dashboard(conn) do 159 | conn |> redirect_to("/dashboards/" <> default_dashboard()) 160 | end 161 | 162 | defp default_dashboard, do: Application.get_env(:kitto, :default_dashboard, "sample") 163 | 164 | defp development_assets_url do 165 | "http://#{Kitto.asset_server_host}:#{Kitto.asset_server_port}/assets/" 166 | end 167 | 168 | defp render_json(conn, json, opts \\ %{status: 200}) do 169 | conn 170 | |> put_resp_header("content-type", "application/json") 171 | |> send_resp(opts.status, Poison.encode!(json)) 172 | end 173 | 174 | defp subscribed_topics(conn) do 175 | case Plug.Conn.fetch_query_params(conn).query_params 176 | |> Map.get("topics", "") 177 | |> String.split(",") do 178 | [""] -> nil 179 | topics -> MapSet.new(topics) 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /test/stats_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Kitto.StatsServerTest do 2 | use ExUnit.Case 3 | 4 | alias Kitto.StatsServer 5 | 6 | defmodule BackoffMock do 7 | @behaviour Kitto.Backoff 8 | 9 | def succeed(_), do: {:ok, :success} 10 | def fail(_), do: {:ok, :fail} 11 | def backoff!(_), do: send self(), {:ok, :backoff} 12 | end 13 | 14 | setup do 15 | Application.put_env :kitto, :backoff_module, Kitto.StatsServerTest.BackoffMock 16 | definition = %{file: "jobs/dummy.exs", line: 1} 17 | job = %{name: :dummy_job, options: %{}, definition: definition, job: fn -> :ok end} 18 | {:ok, server} = StatsServer.start_link(name: :stats_server) 19 | 20 | on_exit fn -> 21 | Application.delete_env :kitto, :backoff_module 22 | Application.delete_env :kitto, :job_backoff_enabled? 23 | server |> Process.exit(:normal) 24 | end 25 | 26 | %{ 27 | successful_job: job, 28 | failing_job: %{job | job: fn -> raise RuntimeError end}, 29 | server: server 30 | } 31 | end 32 | 33 | test "#measure initializes the job stats", %{successful_job: job, server: server} do 34 | server |> StatsServer.measure(job) 35 | 36 | assert StatsServer.stats(server).dummy_job.times_completed == 1 37 | end 38 | 39 | test "#measure when a job succeeds increments :times_triggered", 40 | %{successful_job: job, server: server} do 41 | server |> StatsServer.measure(job) 42 | server |> StatsServer.measure(job) 43 | 44 | assert StatsServer.stats(server).dummy_job.times_triggered == 2 45 | end 46 | 47 | test "#measure when a job fails increments :times_triggered", context do 48 | context.server |> StatsServer.measure(context.successful_job) 49 | assert_raise Kitto.Job.Error, fn -> 50 | context.server |> StatsServer.measure(context.failing_job) 51 | end 52 | 53 | assert StatsServer.stats(context.server).dummy_job.times_triggered == 2 54 | end 55 | 56 | test "#measure when a job succeeds increments :times_completed", 57 | %{successful_job: job, server: server} do 58 | server |> StatsServer.measure(job) 59 | server |> StatsServer.measure(job) 60 | 61 | assert StatsServer.stats(server).dummy_job.times_completed == 2 62 | end 63 | 64 | test "#measure when a job fails does not increment :times_completed", context do 65 | context.server |> StatsServer.measure(context.successful_job) 66 | 67 | assert_raise Kitto.Job.Error, fn -> 68 | context.server |> StatsServer.measure(context.failing_job) 69 | end 70 | 71 | assert StatsServer.stats(context.server).dummy_job.times_completed == 1 72 | end 73 | 74 | test "#measure when a job succeeds increments :total_running_time", context do 75 | context.server |> StatsServer.measure(context.successful_job) 76 | 77 | running_time = StatsServer.stats(context.server).dummy_job.total_running_time 78 | 79 | context.server |> StatsServer.measure(context.successful_job) 80 | 81 | assert StatsServer.stats(context.server).dummy_job.total_running_time >= running_time 82 | end 83 | 84 | test "#measure when a job fails does not increment :total_running_time", context do 85 | context.server |> StatsServer.measure(context.successful_job) 86 | 87 | expected_running_time = StatsServer.stats(context.server).dummy_job.total_running_time 88 | 89 | assert_raise Kitto.Job.Error, fn -> 90 | context.server |> StatsServer.measure(context.failing_job) 91 | end 92 | 93 | actual_running_time = StatsServer.stats(context.server).dummy_job.total_running_time 94 | 95 | assert_in_delta actual_running_time, expected_running_time, 0.1 96 | end 97 | 98 | test "#measure when a job fails, message contains job definition location", context do 99 | job = context.failing_job 100 | 101 | assert_raise Kitto.Job.Error, ~r/Defined in: #{job.definition.file}/, fn -> 102 | context.server |> StatsServer.measure(job) 103 | end 104 | end 105 | 106 | test "#measure when a job fails, message contains job name", context do 107 | job = context.failing_job 108 | 109 | assert_raise Kitto.Job.Error, ~r/Job :#{job.name} failed to run/, fn -> 110 | context.server |> StatsServer.measure(job) 111 | end 112 | end 113 | 114 | test "#measure when a job fails, message contains the original error", context do 115 | job = context.failing_job 116 | 117 | error = Exception.format_banner(:error, %RuntimeError{}) |> Regex.escape 118 | assert_raise Kitto.Job.Error, 119 | ~r/Error: #{error}/, 120 | fn -> context.server |> StatsServer.measure(job) end 121 | end 122 | 123 | test "#measure when a job fails, message contains the stacktrace", context do 124 | job = context.failing_job 125 | 126 | assert_raise Kitto.Job.Error, 127 | ~r/Stacktrace: .*? anonymous fn/, 128 | fn -> context.server |> StatsServer.measure(job) end 129 | end 130 | 131 | describe "when :job_backoff_enabled? is set to false" do 132 | setup [:disable_job_backoff] 133 | 134 | test "#measure does not apply backoffs", context do 135 | context.server |> StatsServer.measure(context.successful_job) 136 | 137 | refute_received {:ok, :backoff} 138 | end 139 | end 140 | 141 | describe "when :job_backoff_enabled? is set to true" do 142 | setup [:enable_job_backoff] 143 | 144 | test "#measure applies backoffs", context do 145 | context.server |> StatsServer.measure(context.successful_job) 146 | 147 | assert_received {:ok, :backoff} 148 | end 149 | end 150 | 151 | describe "when :job_backoff_enabled? is not set" do 152 | test "#measure applies backoffs", context do 153 | context.server |> StatsServer.measure(context.successful_job) 154 | 155 | assert_received {:ok, :backoff} 156 | end 157 | end 158 | 159 | defp disable_job_backoff(_context) do 160 | Application.put_env :kitto, :job_backoff_enabled?, false 161 | end 162 | 163 | defp enable_job_backoff(_context) do 164 | Application.put_env :kitto, :job_backoff_enabled?, true 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/", "web/", "apps/"], 25 | excluded: [~r"/_build/", ~r"/deps/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # If you want to enforce a style guide and need a more traditional linting 34 | # experience, you can change `strict` to `true` below: 35 | # 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | # 41 | color: true, 42 | # 43 | # You can customize the parameters of any check by adding a second element 44 | # to the tuple. 45 | # 46 | # To disable a check put `false` as second element: 47 | # 48 | # {Credo.Check.Design.DuplicatedCode, false} 49 | # 50 | checks: [ 51 | # 52 | ## Consistency Checks 53 | # 54 | {Credo.Check.Consistency.ExceptionNames}, 55 | {Credo.Check.Consistency.LineEndings}, 56 | {Credo.Check.Consistency.ParameterPatternMatching}, 57 | {Credo.Check.Consistency.SpaceAroundOperators}, 58 | {Credo.Check.Consistency.SpaceInParentheses}, 59 | {Credo.Check.Consistency.TabsOrSpaces}, 60 | 61 | # 62 | ## Design Checks 63 | # 64 | # You can customize the priority of any check 65 | # Priority values are: `low, normal, high, higher` 66 | # 67 | {Credo.Check.Design.AliasUsage, priority: :low}, 68 | # For some checks, you can also set other parameters 69 | # 70 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 71 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 72 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 73 | # 74 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 75 | # You can also customize the exit_status of each check. 76 | # If you don't want TODO comments to cause `mix credo` to fail, just 77 | # set this value to 0 (zero). 78 | # 79 | {Credo.Check.Design.TagTODO, exit_status: 2}, 80 | {Credo.Check.Design.TagFIXME}, 81 | 82 | # 83 | ## Readability Checks 84 | # 85 | {Credo.Check.Readability.FunctionNames}, 86 | {Credo.Check.Readability.LargeNumbers}, 87 | # Line length same as mix format 88 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 98}, 89 | {Credo.Check.Readability.ModuleAttributeNames}, 90 | {Credo.Check.Readability.ModuleDoc}, 91 | {Credo.Check.Readability.ModuleNames}, 92 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 93 | {Credo.Check.Readability.ParenthesesInCondition}, 94 | {Credo.Check.Readability.PredicateFunctionNames}, 95 | {Credo.Check.Readability.PreferImplicitTry}, 96 | {Credo.Check.Readability.RedundantBlankLines}, 97 | {Credo.Check.Readability.StringSigils}, 98 | {Credo.Check.Readability.TrailingBlankLine}, 99 | {Credo.Check.Readability.TrailingWhiteSpace}, 100 | {Credo.Check.Readability.VariableNames}, 101 | {Credo.Check.Readability.Semicolons}, 102 | {Credo.Check.Readability.SpaceAfterCommas}, 103 | 104 | # 105 | ## Refactoring Opportunities 106 | # 107 | {Credo.Check.Refactor.DoubleBooleanNegation}, 108 | {Credo.Check.Refactor.CondStatements}, 109 | {Credo.Check.Refactor.CyclomaticComplexity}, 110 | {Credo.Check.Refactor.FunctionArity}, 111 | {Credo.Check.Refactor.LongQuoteBlocks}, 112 | {Credo.Check.Refactor.MatchInCondition}, 113 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 114 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 115 | {Credo.Check.Refactor.Nesting}, 116 | {Credo.Check.Refactor.PipeChainStart, 117 | excluded_argument_types: [:atom, :binary, :fn, :keyword], excluded_functions: []}, 118 | {Credo.Check.Refactor.UnlessWithElse}, 119 | 120 | # 121 | ## Warnings 122 | # 123 | {Credo.Check.Warning.BoolOperationOnSameValues}, 124 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck}, 125 | {Credo.Check.Warning.IExPry}, 126 | {Credo.Check.Warning.IoInspect}, 127 | {Credo.Check.Warning.LazyLogging}, 128 | {Credo.Check.Warning.OperationOnSameValues}, 129 | {Credo.Check.Warning.OperationWithConstantResult}, 130 | {Credo.Check.Warning.UnusedEnumOperation}, 131 | {Credo.Check.Warning.UnusedFileOperation}, 132 | {Credo.Check.Warning.UnusedKeywordOperation}, 133 | {Credo.Check.Warning.UnusedListOperation}, 134 | {Credo.Check.Warning.UnusedPathOperation}, 135 | {Credo.Check.Warning.UnusedRegexOperation}, 136 | {Credo.Check.Warning.UnusedStringOperation}, 137 | {Credo.Check.Warning.UnusedTupleOperation}, 138 | {Credo.Check.Warning.RaiseInsideRescue}, 139 | 140 | # 141 | # Controversial and experimental checks (opt-in, just remove `, false`) 142 | # 143 | {Credo.Check.Refactor.ABCSize, false}, 144 | {Credo.Check.Refactor.AppendSingleItem, false}, 145 | {Credo.Check.Refactor.VariableRebinding, false}, 146 | {Credo.Check.Warning.MapGetUnsafePass, false}, 147 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 148 | 149 | # 150 | # Deprecated checks (these will be deleted after a grace period) 151 | # 152 | {Credo.Check.Readability.Specs, false} 153 | 154 | # 155 | # Custom checks can be created using `mix credo.gen.check`. 156 | # 157 | ] 158 | } 159 | ] 160 | } 161 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## Unreleased 6 | 7 | ## [0.9.2] - 2020-12-06 8 | 9 | ### Changed 10 | 11 | * Updated project to work with more recent version of nodejs (14.15.1) and Webpack 12 | 13 | ## [0.9.1] - 2019-01-15 14 | 15 | ### Changed 16 | 17 | * Bumped version of webpack-dev-server to 3.1.11 and added webpack-cli@3.2.1 18 | 19 | ## [0.9.0] - 2018-10-15 20 | 21 | ### Added 22 | 23 | * Rendering support for templates in subdirectories of templates_dir 24 | 25 | ## [0.8.0] - 2018-03-27 26 | 27 | ### Added 28 | 29 | * Button to make a dashboard fullscreen 30 | 31 | ### Changed 32 | 33 | * [security] jQuery npm dependency is specified with `^3.0.0`, (7c9dff) 34 | 35 | ### Fixed 36 | 37 | * Static asset serving for heroku by providing `:assets_path`, 38 | see: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-Heroku#configure-assets 39 | 40 | ## [0.7.0] - 2017-10-18 41 | 42 | ### Added 43 | 44 | * `Kitto.Notifier.broadcast!/2` supports railroading. 45 | 46 | Example: 47 | 48 | ```elixir 49 | job :ci_status, every: :minute do 50 | # All the combinations below will do the expected thing and infer which 51 | parameter is the topic and which is the message 52 | 53 | CI.status(:awesome_project) |> broadcast! 54 | CI.status(:awesome_project) |> broadcast!(:projects) 55 | CI.status(:awesome_project) |> broadcast!(:projects) 56 | broadcast!(:projects, CI.status(:awesome_project)) 57 | end 58 | ``` 59 | 60 | ### Fixed 61 | 62 | * `mix.kitto new ` check for valid name in OTP 20 63 | * Font loading in development, due to webpack-dev-server not setting CORS headers 64 | 65 | ## [0.6.0] - 2017-04-18 66 | 67 | ### Added 68 | 69 | * Add {edge, dev, app} kitto.new options (see: https://github.com/kittoframework/kitto/blob/v0.6.0/installer/lib/kitto_new.ex#L83) 70 | 71 | ## [0.6.0-rc0] - 2017-04-11 72 | 73 | ### Added 74 | 75 | * Sample distillery config and asset compilation plugin 76 | * Sample `config/dev.exs` and `config/prod.exs` 77 | 78 | ### Changed 79 | 80 | * `Kitto.root` returns `Application.app_dir` when `:root` is set to `:otp_app` 81 | * For newly scaffolded apps, assets are built in `priv/static` 82 | * Static assets are served from `priv/static` of application 83 | * Assets are forwarder to webpack live builder only when `:watch_assets?` is set to true 84 | * Elixir CodeReloader is disabled when `:reload_code?` is set to false 85 | 86 | ## [0.5.2] - 2017-03-30 87 | 88 | ### Fixed 89 | 90 | * Prevent DoS due to Atom creation for event topic subscription (5323717) 91 | * Prevent XSS in 404 page (63570c0) 92 | * Prevent directory traversal for dashboard templates (#103) 93 | 94 | ## [0.5.1] - 2017-02-21 95 | 96 | ### Fixed 97 | 98 | * Added missing package.json to mix.exs 99 | 100 | ## [0.5.0] - 2017-02-19 101 | 102 | ### Changed 103 | 104 | * The core Kitto JavaScript library is now packaged (#39, #72) 105 | Read: [upgrading-guide](https://github.com/kittoframework/kitto/wiki/Upgrading-Guide#050) 106 | 107 | ### Fixed 108 | 109 | * Typo in jobs generated dashboard setting invalid invalid source for 110 | "average time took" widget 111 | 112 | * Compilation warnings for Elixir v1.4 113 | 114 | ## [0.4.0] - 2017-01-12 115 | 116 | ### Added 117 | 118 | * Exponential back-off support for failing jobs (b20064a) 119 | 120 | * Widget generator task 121 | 122 | ```shell 123 | mix kitto.gen.widget weather 124 | # Generates: 125 | # * widgets/weather/weather.js 126 | # * widgets/weather/weather.scss 127 | ``` 128 | 129 | * Job generator task 130 | 131 | ```shell 132 | mix kitto.gen.job weather 133 | # Generates: jobs/weather.exs 134 | ``` 135 | 136 | * Dashboard generator task 137 | 138 | ```shell 139 | mix kitto.gen.dashboard weather 140 | # Generates: dashboards/weather.html.eex 141 | ``` 142 | 143 | ### Changed 144 | 145 | * Warning and danger widget colors are swapped in new generated dashboards 146 | 147 | ## [0.3.2] - 2016-12-22 148 | 149 | ### Fixed 150 | 151 | * Heroku static asset serving bug (see: #77) 152 | * Kitto server not starting when asset watcher bin is missing 153 | 154 | ## [0.3.1] - 2016-12-20 155 | 156 | ### Fixed 157 | 158 | * Code Reloader failure in macOS, see (#65) 159 | 160 | ## [0.3.0] - 2016-12-08 161 | 162 | ### Added 163 | 164 | * `:command` option to job DSL 165 | 166 | Example: 167 | 168 | ```elixir 169 | job :kitto_last_commit, 170 | every: {5, :minutes}, 171 | command: "curl https://api.github.com/repos/kittoframework/kitto/commits\?page\=1\&per_page\=1" 172 | ``` 173 | 174 | Broadcasts JSON in the form `{ "exit_code": "an integer", "stdout": "a string" }` 175 | 176 | * Gist installer gist task 177 | (see: https://github.com/kittoframework/kitto/wiki/Widget-and-Job-Directory#install-widgetsjob-from-a-gist) 178 | * Code reloading in development (see: https://github.com/kittoframework/kitto/wiki/Code-Reloading) 179 | * Job Syntax Validation. When a job contains syntax errors, it is not loaded. 180 | * SSE Events filtering (a7777618) 181 | * [installer] Heroku deployment files (see: https://github.com/kittoframework/kitto/wiki/Deploying-to-Heroku) 182 | * Widget data JSON API (6b8b476c) 183 | * Remote dashboard reloading command (62bd4f90) 184 | 185 | ### Changed 186 | 187 | * Calls to `broadcast/1` inside a job are rewritten to `Kitto.Notifier.broadcast/2` 188 | * Installer checks for app name validity 189 | * The graph type of the graph widget is now configurable (9eeaf5ff) 190 | 191 | ## [0.2.3] - 2016-11-15 192 | 193 | ### Added 194 | 195 | * Kitto :assets_host and :assets_port config settings for the dev asset server 196 | binding address 197 | * Kitto :ip config setting the server binding ip 198 | * Authentication to POST /widgets/:id, (#11) 199 | 200 | ### Changed 201 | 202 | * Scaffolded version of d3 is 3.5.17 gcc, python no longer required for 203 | `npm install` (acbda885) 204 | 205 | ## [0.2.2] - 2016-11-11 206 | 207 | ### Changed 208 | 209 | * Fonts are no longer bundled in js but are served independently 210 | 211 | ### Fixed 212 | 213 | * Font assets are now served in development 214 | * Added missing favicon 215 | 216 | ## [0.2.1] - 2016-11-06 217 | 218 | ### Changed 219 | 220 | * Job error output contains job definition and error locations 221 | * Generated job files have .exs file extension 222 | 223 | ## [0.2.0] - 2016-10-31 224 | 225 | ### Added 226 | 227 | * data-resolution="1080" dashboard attribute (506c6d2) 228 | * labelLength, valueLength props on list widget (566edb13) 229 | * truncate JavaScript helper function 230 | * GET /dashboards redirects to the default dashboard (07d8497f) 231 | * GET / redirects to the default dashboard (99cdef2) 232 | 233 | ## [0.1.1] - 2016-10-22 234 | 235 | ### Added 236 | 237 | * Installer creates a sample jobs dashboard to monitor jobs 238 | 239 | ### Changed 240 | 241 | * Supervisors are supervised using Supervisor.Spec.supervisor/3 242 | 243 | ## [0.1.0] - 2016-10-21 244 | 245 | ### Added 246 | 247 | * Kitto.StatsServer which keeps stats about job runs 248 | * A DSL to declare jobs. See: https://github.com/kittoframework/kitto#jobs 249 | * Kitto.Time declares functions to handle time conversions 250 | * mix kitto.server in :dev env watches assets and rebuilds then 251 | 252 | ### Changed 253 | 254 | * Job processes are named 255 | 256 | ### Removed 257 | 258 | * Kitto.Job.every(options, fun) api is removed 259 | 260 | ## [0.0.5] - 2016-10-10 261 | 262 | ### Added 263 | 264 | * Kitto.Job.new/1 to support streaming jobs without interval 265 | * Job cache. The last broadcasted message of each job is cached and sent 266 | upon connecting to `GET /events` 267 | 268 | ### Changed 269 | 270 | * Supervise Notifier connections cache 271 | * Supervise job processes 272 | 273 | ## [0.0.4] - 2016-10-05 274 | 275 | ### Fixed 276 | 277 | * Properly serve assets in development via Webpack 278 | * Fix deprecation warning caused by :random.uniform 279 | 280 | ## [0.0.3] - 2016-09-25 281 | 282 | ### Added 283 | 284 | * gzipped assets are served in production 285 | * Webpack plugin to produce gzipped assets 286 | 287 | ## [0.0.2] - 2016-09-24 288 | 289 | ### Added 290 | 291 | * Assets are served in production 292 | 293 | ### Fixed 294 | 295 | * Cowboy/Plug are not started twice 296 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![kitto-logo](http://i.imgur.com/GE38c22.png) 2 | 3 | --------------------------------------------- 4 | 5 | [![Build Status](https://travis-ci.org/kittoframework/kitto.svg?branch=master)](https://travis-ci.org/kittoframework/kitto) 6 | [![Package Version](https://img.shields.io/hexpm/v/kitto.svg)](https://hex.pm/packages/kitto) 7 | [![Coverage](https://coveralls.io/repos/github/kittoframework/kitto/badge.svg?branch=master)](https://coveralls.io/github/kittoframework/kitto) 8 | [![Inline docs](http://inch-ci.org/github/kittoframework/kitto.svg)](http://inch-ci.org/github/kittoframework/kitto) 9 | [![Chat on Gitter](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/kittoframework/Lobby) 10 | 11 | Kitto is a framework to help you create dashboards, written in [Elixir][elixir] / [React][react]. 12 | 13 | ## Demo 14 | 15 | ![demo](http://i.imgur.com/YgZibXU.png) 16 | 17 | + [Sample Dashboard](https://kitto.io/dashboards/sample) 18 | + [Elixir Dashboard](https://kitto.io/dashboards/elixir) 19 | + [Jobs Dashboard](https://kitto.io/dashboards/jobs) 20 | + [1080 Dashboard](https://kitto.io/dashboards/sample1080) (optimized for 1080 screens) ([source][source-1080]) 21 | 22 | The source for the demo dashboards can be found at: [kittoframework/demo](https://github.com/kittoframework/demo). 23 | 24 | To start creating your own, read [below](https://github.com/kittoframework/kitto#create-a-dashboard). 25 | 26 | ## Features 27 | 28 | * Jobs are supervised processes running concurrently 29 | * Widgets are coded in the popular [React][react] library 30 | * Uses a modern asset tool-chain, [Webpack][webpack] 31 | * Allows streaming SSE to numerous clients concurrently with low 32 | memory/CPU footprint 33 | * Easy to deploy using the provided Docker images, Heroku ([guide][wiki-heroku]) 34 | or [Distillery][distillery] ([guide][wiki-distillery]) 35 | * Can serve assets in production 36 | * Keeps stats about defined jobs and comes with a dashboard to monitor them ([demo][demo-jobs]) 37 | * Can apply exponential back-offs to failing jobs 38 | * [Reloads][code-reloading] code upon change in development 39 | 40 | ## Installation 41 | 42 | Install the latest archive 43 | 44 | ```shell 45 | mix archive.install https://github.com/kittoframework/archives/raw/master/kitto_new-0.9.2.ez 46 | ``` 47 | 48 | ## Requirements 49 | 50 | * `Elixir`: >= 1.3 51 | * `Erlang/OTP`: >= 19 52 | 53 | ### Assets 54 | 55 | * `Node`: 14.15.1 56 | * `npm`: 6.14.9 57 | 58 | It may inadvertently work in versions other than the above, but it won't have been 59 | thoroughly tested (see [.travis.yml][.travis.yml] for the defined build matrix). 60 | 61 | You may also use the official [Docker image](https://github.com/kittoframework/kitto#using-docker). 62 | 63 | Please open an issue to request support for a specific platform. 64 | 65 | ## Create a dashboard 66 | 67 | ```shell 68 | mix kitto.new 69 | ``` 70 | 71 | ## Development 72 | 73 | Install dependencies 74 | 75 | ```shell 76 | mix deps.get && npm install 77 | ``` 78 | 79 | Start a Kitto server (also watches for assets changes) 80 | 81 | ```shell 82 | mix kitto.server 83 | ``` 84 | 85 | Try the sample dashboard at: [http://localhost:4000/dashboards/sample](http://localhost:4000/dashboards/sample) 86 | 87 | For configuration options and troubleshooting be sure to consult the 88 | [wiki][wiki]. 89 | 90 | ## The dashboard grid 91 | 92 | Kitto is capable of serving multiple dashboards. Each one of them is 93 | served from a path of the following form `/dashboards/`. 94 | 95 | A dashboard consists of a [Gridster](http://dsmorse.github.io/gridster.js/) grid containing [React](https://facebook.github.io/react/) widgets. 96 | 97 | You will find a sample dashboard under `dashboards/sample`. 98 | 99 | The snippet below will place a simple `Text` widget in the dashboard. 100 | 101 | ```html 102 |
  • 103 |
    109 |
  • 110 | ``` 111 | 112 | The most important data attributes here are 113 | 114 | * `data-widget` Selects the widget to be used. See: [Widgets](https://github.com/kittoframework/kitto#widgets) 115 | * `data-source` Selects the data source to populate the widget. See: [Jobs](https://github.com/kittoframework/kitto#jobs) 116 | 117 | The other data attributes are options to be passed as props to the React widget. 118 | 119 | ## Jobs 120 | 121 | By creating a new dashboard using `mix kitto.new ` you get 122 | a few sample jobs in the directory `jobs/`. 123 | 124 | A job file is structured as follows: 125 | 126 | ```elixir 127 | # File jobs/random.exs 128 | use Kitto.Job.DSL 129 | 130 | job :random, every: :second do 131 | broadcast! :random, %{value: :rand.uniform * 100 |> Float.round} 132 | end 133 | ``` 134 | 135 | The above will spawn a supervised process which will emit a [server-sent 136 | event](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) with the name `random` every second. 137 | 138 | Jobs can also run commands on the server. Data broadcast using commands is in 139 | the form `{exit_code: integer, stdout: String.t}`. For example the following 140 | job will broadcast a `kitto_last_commit` event with the results of the `curl` 141 | statement: 142 | 143 | ```elixir 144 | job :kitto_last_commit, 145 | every: {5, :minutes}, 146 | command: "curl https://api.github.com/repos/kittoframework/kitto/commits\?page\=1\&per_page\=1" 147 | ``` 148 | 149 | You can set a job to start at a later time using the `first_at` option: 150 | 151 | ```elixir 152 | # Delay the first run by 2 minutes 153 | job :random, every: :second, first_at: {2, :minutes} do 154 | broadcast! :random, %{value: :rand.uniform * 100 |> Float.round} 155 | end 156 | ``` 157 | 158 | ## Widgets 159 | 160 | Widgets live in `widgets/` are compiled using 161 | [Webpack](https://webpack.github.io/) and are automatically loaded in the dashboards. 162 | Assets are rebuilt upon change in development, but have to be compiled 163 | for production. See `webpack.config.js` for build options. 164 | 165 | Example widget (`widgets/text/text.js`) 166 | 167 | ```javascript 168 | import React from 'react'; 169 | import Widget from '../../assets/javascripts/widget'; 170 | 171 | import './text.scss'; 172 | 173 | Widget.mount(class Text extends Widget { 174 | render() { 175 | return ( 176 |
    177 |

    {this.props.title}

    178 |

    {this.state.text || this.props.text}

    179 |

    {this.props.moreinfo}

    180 |
    181 | ); 182 | } 183 | }); 184 | ``` 185 | 186 | Each widget is updated with data from one source specified using the 187 | `data-source` attribute. 188 | 189 | ## Deployment 190 | 191 | 192 | ### Deployment Guides 193 | 194 | [distillery][wiki.distillery] | [docker][wiki.docker] | [heroku][wiki.heroku] | [systemd][wiki.systemd] 195 | 196 | Compile the project 197 | 198 | ```shell 199 | MIX_ENV=prod mix compile 200 | ``` 201 | 202 | Compile assets for production 203 | 204 | ```shell 205 | npm run build 206 | ``` 207 | 208 | Start the server 209 | 210 | ```shell 211 | MIX_ENV=prod mix kitto.server 212 | ``` 213 | 214 | #### Using Docker 215 | 216 | By scaffolding a new dashboard with: 217 | 218 | ```shell 219 | mix kitto.new 220 | ``` 221 | 222 | you also get a `Dockerfile`. 223 | 224 | Build an image including your code, ready to be deployed. 225 | 226 | ```shell 227 | docker build . -t my-awesome-dashboard 228 | ``` 229 | 230 | Spawn a container of the image 231 | 232 | ```shell 233 | docker run -i -p 127.0.0.1:4000:4000 -t my-awesome-dashboard 234 | ``` 235 | 236 | #### Heroku 237 | 238 | Please read the detailed [instructions][wiki-heroku] in the wiki. 239 | 240 | ### Upgrading 241 | 242 | Please read the [upgrading guide][upgrading-guide] in the wiki. 243 | 244 | ### Contributing 245 | #### Run the Tests 246 | 247 | ```shell 248 | mix test 249 | ``` 250 | 251 | #### Run the Linter 252 | 253 | ```shell 254 | mix credo 255 | ``` 256 | 257 | ### Support 258 | 259 | Have a question? 260 | 261 | * Check the [wiki][wiki] first 262 | * See [elixirforum/kitto](https://elixirforum.com/t/kitto-a-framework-for-interactive-dashboards) 263 | * Open an [issue](https://github.com/kittoframework/kitto/issues/new) 264 | * Ask in [gitter.im/kittoframework](https://gitter.im/kittoframework/Lobby) 265 | 266 | ### Inspiration 267 | 268 | It is heavily inspired by [shopify/dashing](http://dashing.io/). :heart: 269 | 270 | ### About the name 271 | 272 | The [road to Erlang / Elixir](https://www.google.gr/maps/place/Erlanger+Rd,+London) starts with [Kitto](https://en.wikipedia.org/wiki/H._D._F._Kitto). 273 | 274 | # LICENSE 275 | 276 | Copyright (c) 2017 Dimitris Zorbas, MIT License. 277 | See [LICENSE.txt](https://github.com/kittoframework/kitto/blob/master/LICENSE.txt) for further details. 278 | 279 | Logo by Vangelis Tzortzis ([github][srekoble-github] / [site][srekoble-site]). 280 | 281 | [elixir]: http://elixir-lang.org 282 | [react]: https://facebook.github.io/react/ 283 | [webpack]: https://webpack.github.io/ 284 | [gridster]: http://dsmorse.github.io/gridster.js/ 285 | [wiki]: https://github.com/kittoframework/kitto/wiki 286 | [wiki-heroku]: https://github.com/kittoframework/kitto/wiki/Deploying-to-Heroku 287 | [code-reloading]: https://github.com/kittoframework/kitto/wiki/Code-Reloading 288 | [upgrading-guide]: https://github.com/kittoframework/kitto/wiki/Upgrading-Guide 289 | [.travis.yml]: https://github.com/kittoframework/kitto/blob/master/.travis.yml 290 | [distillery]: https://github.com/bitwalker/distillery 291 | [wiki-heroku]: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-Heroku 292 | [wiki-distillery]: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-Distillery 293 | [demo-jobs]: https://kitto.io/dashboards/jobs 294 | [wiki.distillery]: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-Distillery 295 | [wiki.docker]: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-Docker 296 | [wiki.heroku]: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-Heroku 297 | [wiki.systemd]: https://github.com/kittoframework/kitto/wiki/%5BDeployment%5D-systemd-Unit 298 | [source-1080]: https://github.com/kittoframework/demo/blob/master/dashboards/sample1080.html.eex 299 | [srekoble-github]: https://github.com/srekoble 300 | [srekoble-site]: https://vangeltzo.com/ 301 | -------------------------------------------------------------------------------- /installer/lib/kitto_new.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Kitto.New do 2 | use Mix.Task 3 | import Mix.Generator 4 | 5 | @version Mix.Project.config[:version] 6 | @shortdoc "Creates a new Kitto v#{@version} application" 7 | @repo "https://github.com/kittoframework/kitto" 8 | 9 | # File mappings 10 | # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength 11 | @new [ 12 | {:eex, "new/config/config.exs", "config/config.exs"}, 13 | {:text, "new/config/dev.exs", "config/dev.exs"}, 14 | {:text, "new/config/prod.exs", "config/prod.exs"}, 15 | {:eex, "new/rel/config.exs", "rel/config.exs"}, 16 | {:text, "new/rel/plugins/compile_assets_task.exs", "rel/plugins/compile_assets_task.exs"}, 17 | {:eex, "new/mix.exs", "mix.exs"}, 18 | {:eex, "new/README.md", "README.md"}, 19 | {:text, "new/.gitignore", ".gitignore"}, 20 | {:text, "new/Dockerfile", "Dockerfile"}, 21 | {:text, "new/.dockerignore", ".dockerignore"}, 22 | {:text, "new/Procfile", "Procfile"}, 23 | {:text, "new/elixir_buildpack.config", "elixir_buildpack.config"}, 24 | {:eex, "new/lib/application_name.ex", "lib/application_name.ex"}, 25 | {:text, "new/dashboards/error.html.eex", "dashboards/error.html.eex"}, 26 | {:text, "new/dashboards/layout.html.eex", "dashboards/layout.html.eex"}, 27 | {:text, "new/dashboards/sample.html.eex", "dashboards/sample.html.eex"}, 28 | {:text, "new/dashboards/rotator.html.eex", "dashboards/rotator.html.eex"}, 29 | {:text, "new/dashboards/jobs.html.eex", "dashboards/jobs.html.eex"}, 30 | {:text, "new/widgets/clock/clock.js", "widgets/clock/clock.js"}, 31 | {:text, "new/widgets/clock/clock.scss", "widgets/clock/clock.scss"}, 32 | {:text, "new/widgets/graph/graph.js", "widgets/graph/graph.js"}, 33 | {:text, "new/widgets/graph/graph.scss", "widgets/graph/graph.scss"}, 34 | {:text, "new/widgets/image/image.js", "widgets/image/image.js"}, 35 | {:text, "new/widgets/image/image.scss", "widgets/image/image.scss"}, 36 | {:text, "new/widgets/list/list.js", "widgets/list/list.js"}, 37 | {:text, "new/widgets/list/list.scss", "widgets/list/list.scss"}, 38 | {:text, "new/widgets/number/number.js", "widgets/number/number.js"}, 39 | {:text, "new/widgets/number/number.scss", "widgets/number/number.scss"}, 40 | {:text, "new/widgets/meter/meter.js", "widgets/meter/meter.js"}, 41 | {:text, "new/widgets/meter/meter.scss", "widgets/meter/meter.scss"}, 42 | {:text, "new/widgets/text/text.js", "widgets/text/text.js"}, 43 | {:text, "new/widgets/text/text.scss", "widgets/text/text.scss"}, 44 | {:text, "new/widgets/time_took/time_took.js", "widgets/time_took/time_took.js"}, 45 | {:text, "new/widgets/time_took/time_took.scss", "widgets/time_took/time_took.scss"}, 46 | {:text, "new/jobs/phrases.exs", "jobs/phrases.exs"}, 47 | {:text, "new/jobs/convergence.exs", "jobs/convergence.exs"}, 48 | {:text, "new/jobs/buzzwords.exs", "jobs/buzzwords.exs"}, 49 | {:text, "new/jobs/random.exs", "jobs/random.exs"}, 50 | {:text, "new/jobs/stats.exs", "jobs/stats.exs"}, 51 | {:keep, "new/assets/images", "assets/images/"}, 52 | {:keep, "new/assets/fonts", "assets/fonts/"}, 53 | {:text, "new/assets/javascripts/application.js", "assets/javascripts/application.js"}, 54 | {:text, "new/assets/stylesheets/application.scss", "assets/stylesheets/application.scss"}, 55 | {:keep, "new/public/assets", "public/assets"}, 56 | {:text, "new/public/assets/favicon.ico", "public/assets/favicon.ico"}, 57 | {:text, "new/public/assets/images/placeholder.png", "public/assets/images/placeholder.png"}, 58 | {:text, "new/webpack.config.js", "webpack.config.js"}, 59 | {:text, "new/.babelrc", ".babelrc"}, 60 | {:eex, "new/package.json", "package.json"} 61 | ] 62 | 63 | # Embed all defined templates 64 | root = Path.expand("../templates", __DIR__) 65 | 66 | for {format, source, _} <- @new do 67 | unless format == :keep do 68 | @external_resource Path.join(root, source) 69 | def render(unquote(source)), do: unquote(File.read!(Path.join(root, source))) 70 | end 71 | end 72 | 73 | @moduledoc """ 74 | Creates a new Kitto dashboard. 75 | 76 | It expects the path of the project as argument. 77 | 78 | mix kitto.new PATH [--edge] [--dev KITTO_PATH] [--app APP_NAME] 79 | 80 | A project at the given PATH will be created. The application name and module 81 | name will be retrieved from the path, unless otherwise provided. 82 | 83 | ## Options 84 | 85 | * `--edge` - use the `master` branch of Kitto as your dashboard's dependency 86 | * `--dev` - use a local copy of Kitto as your dashboard's dependency 87 | * `--app` - name of the OTP application and base module 88 | 89 | ## Examples 90 | 91 | # Create a new Kitto dashboard 92 | mix kitto.new hello_world 93 | 94 | # Create a new Kitto dashboard named `Foo` in `./hello_world` 95 | mix kitto.new hello_world --app foo 96 | 97 | # Create a new Kitto dashboard using the master branch to get the latest 98 | # Kitto features 99 | mix kitto.new hello_world --edge 100 | 101 | # Create a new Kitto dashboard using a local copy at ./kitto to test 102 | # development code in Kitto core 103 | mix kitto.new hello_world --dev ./kitto 104 | 105 | See: https://github.com/kittoframework/demo 106 | """ 107 | 108 | def run([version]) when version in ~w(-v --version) do 109 | Mix.shell.info "Kitto v#{@version}" 110 | end 111 | 112 | def run(argv) do 113 | {opts, argv} = 114 | case OptionParser.parse(argv, strict: [edge: :boolean, dev: :string, app: :string]) do 115 | {opts, argv, []} -> 116 | {opts, argv} 117 | {_opts, _argv, [switch | _]} -> 118 | Mix.raise "Invalid option: " <> switch_to_string(switch) 119 | end 120 | 121 | case argv do 122 | [] -> Mix.Task.run "help", ["kitto.new"] 123 | [path|_] -> 124 | app = String.downcase(opts[:app] || Path.basename(path)) 125 | check_application_name!(app) 126 | mod = Macro.camelize(app) 127 | 128 | run(app, mod, path, opts) 129 | end 130 | end 131 | 132 | def run(app, mod, path, opts) do 133 | binding = [application_name: app, 134 | application_module: mod, 135 | kitto_dep: kitto_dep(opts), 136 | npm_kitto_dep: npm_kitto_dep(opts[:dev])] 137 | 138 | copy_from path, binding, @new 139 | 140 | ## Optional contents 141 | 142 | ## Parallel installs 143 | install? = Mix.shell.yes?("\nFetch and install dependencies?") 144 | 145 | File.cd!(path, fn -> 146 | mix? = install_mix(install?) 147 | webpack? = install_webpack(install?) 148 | extra = if mix?, do: [], else: ["$ mix deps.get"] 149 | 150 | print_mix_info(path, extra) 151 | if !webpack?, do: print_webpack_info() 152 | end) 153 | end 154 | 155 | defp switch_to_string({name, nil}), do: name 156 | defp switch_to_string({name, val}), do: name <> "=" <> val 157 | 158 | defp install_webpack(install?) do 159 | maybe_cmd "npm install", 160 | File.exists?("webpack.config.js"), 161 | install? && System.find_executable("npm") 162 | end 163 | 164 | defp install_mix(install?) do 165 | maybe_cmd "mix deps.get", true, install? && Code.ensure_loaded?(Hex) 166 | end 167 | 168 | defp print_mix_info(path, extra) do 169 | steps = ["$ cd #{path}"] ++ extra ++ ["$ mix kitto.server"] 170 | 171 | Mix.shell.info """ 172 | 173 | We are all set! Run your Dashboard application: 174 | 175 | #{Enum.join(steps, "\n ")} 176 | 177 | To access generators compile your application first with: 178 | 179 | $ mix compile 180 | 181 | You can also run your app inside IEx (Interactive Elixir) as: 182 | 183 | $ iex -S mix 184 | """ 185 | end 186 | 187 | defp print_webpack_info do 188 | Mix.shell.info """ 189 | 190 | Kitto uses an assets build tool called webpack 191 | which requires node.js and npm. Installation instructions for 192 | node.js, which includes npm, can be found at http://nodejs.org. 193 | 194 | After npm is installed, install your webpack dependencies by 195 | running inside your app: 196 | 197 | $ npm install 198 | """ 199 | nil 200 | end 201 | 202 | def recompile(regex) do 203 | if Code.ensure_loaded?(Regex) and function_exported?(Regex, :recompile!, 1) do 204 | apply(Regex, :recompile!, [regex]) 205 | else 206 | regex 207 | end 208 | end 209 | 210 | defp check_application_name!(app_name) do 211 | unless app_name =~ recompile(~r/^[a-z][\w_]*$/) do 212 | Mix.raise "Application name must start with a letter and have only " <> 213 | "lowercase letters, numbers and underscore, " <> 214 | "received: #{inspect app_name}" 215 | end 216 | end 217 | 218 | ### Helpers 219 | 220 | defp maybe_cmd(cmd, should_run?, can_run?) do 221 | cond do 222 | should_run? && can_run? -> 223 | cmd(cmd) 224 | true 225 | should_run? -> 226 | false 227 | true -> 228 | true 229 | end 230 | end 231 | 232 | defp cmd(cmd) do 233 | Mix.shell.info [:green, "* running ", :reset, cmd] 234 | Mix.shell.cmd(cmd, quiet: true) 235 | end 236 | 237 | defp kitto_dep(opts) do 238 | cond do 239 | opts[:edge] -> ~s[{:kitto, github: "kittoframework/kitto", branch: "master"}] 240 | opts[:dev] -> ~s[{:kitto, path: "#{kitto_path(opts[:dev])}"}] 241 | true -> ~s[{:kitto, "~> #{@version}"}] 242 | end 243 | end 244 | 245 | defp npm_kitto_dep(path) when is_bitstring(path), do: kitto_path(path) 246 | defp npm_kitto_dep(_), do: "deps/kitto" 247 | 248 | defp kitto_path(path) do 249 | {:ok, cwd} = File.cwd() 250 | path = Path.join([cwd, path]) 251 | 252 | if File.exists?(path) do 253 | path 254 | else 255 | install? = Mix.shell.yes?("\nKitto not found. Do you want to clone it?") 256 | maybe_cmd("git clone #{@repo}", true, install?) 257 | path 258 | end 259 | end 260 | 261 | ### Template helpers 262 | 263 | defp copy_from(target_dir, binding, mapping) when is_list(mapping) do 264 | application_name = Keyword.fetch!(binding, :application_name) 265 | 266 | for {format, source, target_path} <- mapping do 267 | target = Path.join(target_dir, 268 | String.replace(target_path, 269 | "application_name", 270 | application_name)) 271 | 272 | case format do 273 | :keep -> 274 | File.mkdir_p!(target) 275 | :text -> 276 | create_file(target, render(source)) 277 | :append -> 278 | append_to(Path.dirname(target), Path.basename(target), render(source)) 279 | :eex -> 280 | contents = EEx.eval_string(render(source), binding, file: source) 281 | create_file(target, contents) 282 | end 283 | end 284 | end 285 | 286 | defp append_to(path, file, contents) do 287 | file = Path.join(path, file) 288 | File.write!(file, File.read!(file) <> contents) 289 | end 290 | end 291 | --------------------------------------------------------------------------------