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 |
\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 = "
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 | 
2 |
3 | ---------------------------------------------
4 |
5 | [](https://travis-ci.org/kittoframework/kitto)
6 | [](https://hex.pm/packages/kitto)
7 | [](https://coveralls.io/github/kittoframework/kitto)
8 | [](http://inch-ci.org/github/kittoframework/kitto)
9 | [](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 | 
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 |
--------------------------------------------------------------------------------