├── resources ├── public │ ├── favicon.ico │ ├── font │ │ ├── FontAwesome.otf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.ttf │ │ └── fontawesome-webfont.woff │ └── css │ │ ├── styles.css │ │ └── vendor │ │ └── font-awesome.css ├── roles.html ├── alert_email.html ├── layout.html ├── index.html └── roles_edit.html ├── .gitignore ├── bin └── run_app.sh ├── Vagrantfile ├── src ├── lobos │ ├── config.clj │ └── migrations.clj └── watchman │ ├── dev_sandbox.clj │ ├── api_v1_handler.clj │ ├── utils.clj │ ├── models.clj │ ├── pinger.clj │ └── handler.clj ├── test └── watchman │ └── test │ └── pinger_test.clj ├── README.md └── project.clj /resources/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/watchman/HEAD/resources/public/favicon.ico -------------------------------------------------------------------------------- /resources/public/font/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/watchman/HEAD/resources/public/font/FontAwesome.otf -------------------------------------------------------------------------------- /resources/public/font/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/watchman/HEAD/resources/public/font/fontawesome-webfont.eot -------------------------------------------------------------------------------- /resources/public/font/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/watchman/HEAD/resources/public/font/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /resources/public/font/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/watchman/HEAD/resources/public/font/fontawesome-webfont.woff -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | *.jar 3 | *.class 4 | .lein-deps-sum 5 | .lein-failures 6 | .lein-plugins 7 | .lein-env 8 | log/*.log 9 | .lein-repl-history 10 | target/ 11 | .vagrant 12 | .nrepl-port 13 | ansible/vars/env.yml 14 | -------------------------------------------------------------------------------- /resources/roles.html: -------------------------------------------------------------------------------- 1 |
2 |

Roles

3 |
    4 |
  1. 5 | Role 1 6 | 7 |
  2. 8 |
9 |

10 | Create 11 |

12 |
13 | -------------------------------------------------------------------------------- /bin/run_app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # environment.sh is present after a production deploy. 4 | if [ -f environment.sh ]; then 5 | source environment.sh 6 | fi 7 | 8 | if [ "$RING_ENV" == "production" ]; then 9 | java -Xmx1g -Xms1g -server -jar target/watchman-0.1.0-SNAPSHOT-standalone.jar 10 | else 11 | lein ring server-headless 12 | fi 13 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure("2") do |config| 2 | config.vm.box = "precise64" 3 | config.vm.box_url = "http://files.vagrantup.com/precise64.box" 4 | config.vm.host_name = "watchman-vagrant" 5 | config.vm.network "forwarded_port", guest: 80, host: 8010 6 | config.vm.network "forwarded_port", guest: 22, host: 2210 7 | config.ssh.port = 2210 8 | end 9 | -------------------------------------------------------------------------------- /src/lobos/config.clj: -------------------------------------------------------------------------------- 1 | (ns lobos.config) 2 | 3 | (def db 4 | {:classname "org.postgresql.Driver" 5 | :subprotocol "postgresql" 6 | :subname (let [host (or (System/getenv "WATCHMAN_DB_HOST") "localhost") 7 | db-name (or (System/getenv "WATCHMAN_DB_NAME") "watchman")] 8 | (str "//" host ":5432/" db-name)) 9 | :user (or (System/getenv "WATCHMAN_DB_USER") (System/getenv "USER")) 10 | :password (or (System/getenv "WATCHMAN_DB_PASS") "")}) 11 | -------------------------------------------------------------------------------- /resources/alert_email.html: -------------------------------------------------------------------------------- 1 | 4 |
5 | ssh
6 | Status:
7 |
8 | HTTP status code:
9 | Body:
10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /resources/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Watchman 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 |
17 |
18 | 24 |
25 |
26 |
27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /test/watchman/test/pinger_test.clj: -------------------------------------------------------------------------------- 1 | (ns watchman.test.pinger-test 2 | (require [midje.sweet :refer :all] 3 | [watchman.pinger :refer :all] 4 | [midje.util :refer [testable-privates]])) 5 | 6 | (testable-privates watchman.pinger alert-email-html) 7 | 8 | (def check-status {:host_id 1 9 | :hosts {:hostname "the-hostname.com"} 10 | :checks {:path "/the-path"} 11 | :status "down" 12 | :last_response_body "the-body" 13 | :last_response_status_code 500 14 | :content_type "text/html"}) 15 | 16 | (defn- stringify [list] (apply str list)) 17 | 18 | (facts "alert-html-email" 19 | (fact "doesn't escape HTML when the last response's content type is HTML" 20 | (-> check-status 21 | (assoc :last_response_body "" :last_response_content_type "text/html") 22 | alert-email-html 23 | stringify) => (contains "")) 24 | (fact "escapes HTML chars when the last response's content type is non-HTML" 25 | (-> check-status 26 | (assoc :last_response_body "" :last_response_content_type "text/plain") 27 | alert-email-html 28 | stringify) => (contains "<html_tag>"))) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Watchman 2 | 3 | Details coming soon. 4 | 5 | ## Getting started 6 | 7 | lein lobos migrate 8 | bin/run_app.sh 9 | 10 | ## Developing 11 | 12 | To run the tests: 13 | 14 | lein midje 15 | 16 | * For UI changes, use the classic workflow: modify the source and refresh the browser. 17 | * For non-UI changes, running code via the REPL is the fastest way to test and iterate. Sending emails and 18 | polling checks requires a bit of state to exercise. The `dev-sandbox` namespace has a few functions to 19 | quickly create some state for testing/development, and some commonly-used code for sending emails, for 20 | instance. 21 | 22 | ## REST API 23 | 24 | Watchman has a RESTful HTTP API for programatically managing data. 25 | 26 | All API routes require HTTP Basic authentication: 27 | 28 | curl --user username:password -X DELETE http://watchman-hostname.com/api/v1/roles/1/hosts/example.com 29 | 30 | ### Routes 31 | 32 | Adding a host to a role: 33 | 34 | Params: 35 | `hostname`: Required. The hostname to add to the role. 36 | 37 | POST /api/v1/roles/{id}/hosts 38 | 39 | Example response: 40 | 41 | { 42 | "id" : 11, 43 | "hostname" : "example.com" 44 | } 45 | 46 | Remove a host from a role: 47 | 48 | DELETE /api/v1/roles/{id}/hosts/{hostname} 49 | -------------------------------------------------------------------------------- /src/watchman/dev_sandbox.clj: -------------------------------------------------------------------------------- 1 | (ns watchman.dev-sandbox 2 | "Helper functions for developing from the repl: creating fixtures in the data model, sending emails, etc." 3 | (:require [korma.incubator.core :as k] 4 | [watchman.pinger :as pinger] 5 | [watchman.models :as models] 6 | [watchman.utils :refer [sget]])) 7 | 8 | (defn get-development-check-status 9 | "Retrieves the check-status created by `create-development-check`." 10 | [] 11 | (when-let [development-check (first (k/select models/checks 12 | (k/where {:path "/dev"}) 13 | (k/with-object models/check-statuses)))] 14 | (-> development-check (sget :check_statuses) first (sget :id) models/get-check-status-by-id))) 15 | 16 | (defn create-development-role-and-check 17 | "Creates a role and check for development purposes, if this check doesn't already exist. This is equivalent 18 | to clicking through the UI to create these entities in the database." 19 | [] 20 | (when-not (get-development-check-status) 21 | (let [role-id (-> (models/create-role {:name "dev role"}) (sget :id)) 22 | check-id (-> (models/create-check {:path "/dev" :role_id role-id}) (sget :id)) 23 | host-id (-> (models/create-host {:hostname "localhost"}) (sget :id))] 24 | (models/add-check-to-role check-id role-id) 25 | (models/add-host-to-role host-id role-id)))) 26 | 27 | (defn send-test-email [] 28 | (let [check-status (-> (get-development-check-status) 29 | (merge {:status "down"}))] 30 | (pinger/send-email 31 | check-status 32 | "from" 33 | "to" 34 | (merge pinger/smtp-credentials 35 | {:user "username" 36 | :pass "password"})))) 37 | 38 | ; 39 | ; Commonly used code when developing: 40 | ; 41 | 42 | #_(send-test-email) 43 | 44 | #_(pinger/perform-check (-> (get-development-check-status) 45 | (assoc-in [:hosts :hostname] "google.com")) 46 | false) 47 | -------------------------------------------------------------------------------- /resources/index.html: -------------------------------------------------------------------------------- 1 |
2 | 33 | 34 |
35 | Order by 36 | host | status 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 63 | 69 | 70 |
HostAlertLast checkedStatus changedStatus
50 | sample hostname 51 | sample nameSample timestampSample timestamp 56 | 57 | 58 | 59 | 60 | 61 | 62 | 64 | 65 | 66 | 67 | 68 |
71 |
72 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject watchman "0.1.0-SNAPSHOT" 2 | :description "Keeping watch during the night." 3 | :url "http://github.com/philc/watchman" 4 | :main watchman.handler 5 | ; This awt.headless=true prevents some java lib (possibly JNA) from popping up a window upon startup. 6 | ; http://stackoverflow.com/questions/11740012/clojure-java-pop-up-window 7 | :jvm-opts ["-Xmx1g" "-Xms1g" "-server" "-Djava.awt.headless=true"] 8 | :dependencies [[org.clojure/clojure "1.5.1"] 9 | [enlive "1.1.4"] ; HTML transformation 10 | [ring/ring-devel "1.2.0"] ; For displaying backtraces during requests. 11 | [com.draines/postal "1.11.0"] ; For sending emails. 12 | [clj-http "0.7.0"] 13 | [com.cemerick/friend "0.2.0"] ; For authentication. 14 | [compojure "1.1.5"] 15 | [ring/ring-jetty-adapter "1.2.0"] 16 | ; Ring depends on clj-stacktrace, but 0.2.5 includes a critical bugfix. 17 | ; https://github.com/mmcgrana/clj-stacktrace/issues/14 18 | [clj-stacktrace "0.2.5"] 19 | [watchtower "0.1.1"] ; For reloading modified HTML templates during development. 20 | ; Korma requires a recent jdbc, but lein nondeterministically pulls in an old version. 21 | [org.clojure/java.jdbc "0.2.2"] 22 | [org.clojars.harob/korma.incubator "0.1.1-SNAPSHOT"] ; Korma, our SQL ORM. 23 | [postgresql "9.1-901.jdbc4"] ; Postgres driver 24 | ; log4j is included to squelch the verbose initialization output from Korma. 25 | [lobos "1.0.0-beta1"] ; Migrations 26 | [log4j "1.2.15" :exclusions [javax.mail/mail 27 | javax.jms/jms 28 | com.sun.jdmk/jmxtools 29 | com.sun.jmx/jmxri]] 30 | [cheshire "4.0.1"] ; JSON. 31 | [org.clojure/core.incubator "0.1.2"] ; for the -?> operator. 32 | [overtone/at-at "1.2.0"] ; For scheduling recurring tasks. 33 | [clj-time "0.4.4"] 34 | [slingshot "0.10.3"] 35 | [mississippi "1.0.1"] ; Model validation 36 | [ring-mock "0.1.3"]] 37 | :plugins [[lein-ring "0.8.2"] 38 | [lein-lobos "1.0.0-beta1"]] 39 | :ring {:handler watchman.handler/app 40 | :init watchman.handler/init 41 | :port 8130} 42 | :profiles 43 | {:dev {:dependencies [[midje "1.4.0"] 44 | [midje-html-checkers "1.0.1"] ; This seems to not work with midje 1.5 45 | ; Used by lein-midje; see https://github.com/marick/lein-midje/issues/35 46 | [bultitude "0.1.7"]] 47 | :plugins [[lein-midje "2.0.4"]]} 48 | :release {:aot :all}}) 49 | -------------------------------------------------------------------------------- /src/watchman/api_v1_handler.clj: -------------------------------------------------------------------------------- 1 | (ns watchman.api-v1-handler 2 | (:use compojure.core) 3 | (:require [compojure.handler :as handler] 4 | [korma.incubator.core :as k] 5 | [korma.db :refer [transaction]] 6 | [cheshire.core :as json] 7 | [mississippi.core :as m] 8 | [ring.util.response :refer [redirect]] 9 | [watchman.utils :refer [validate-hostname log-info snooze-message]] 10 | [watchman.models :as models])) 11 | 12 | (defn- create-json-error-response 13 | "Returns a JSON response body of the form: {'reason': 'message'}" 14 | [error-code message] 15 | {:status error-code 16 | :headers {"Content-Type" "application/json"} 17 | :body (json/generate-string {:reason message})}) 18 | 19 | (defn- create-json-validation-error-response 20 | "Generates JSON error response for the return value of validate-params" 21 | [[error-field error-message]] 22 | (create-json-error-response 400 (str (name error-field) " " error-message))) 23 | 24 | (defn- create-json-response 25 | "Optional keyword args: 26 | - :raw: If true, assume response-data is already serialized and do not re-serialize it" 27 | [response-data & {:keys [raw]}] 28 | {:headers {"Content-Type" "application/json"} 29 | :body (if raw response-data (json/generate-string response-data))}) 30 | 31 | (defn validate-params 32 | "Runs the validators against the given params map, and returns the first error found, or nil 33 | if everything validated successfully." 34 | [params validation-map] 35 | (let [error-map (m/errors params validation-map)] 36 | (when-not (empty? error-map) 37 | (let [[field errors] (first error-map)] 38 | [field (first errors)])))) 39 | 40 | (def host-validation-map 41 | {:hostname [(m/required :msg "is required")]}) 42 | 43 | (defn- wrap-roles-routes 44 | "Wraps /roles/:id routes to load the role or halt with 404 if it doesn't exist." 45 | [handler] 46 | (fn [{:keys [params] :as request}] 47 | (if-let [role (models/get-role-by-id (Integer/parseInt (:id params)))] 48 | (handler (assoc request :role role)) 49 | (create-json-error-response 404 "role does not exist")))) 50 | 51 | (defroutes role-api-routes 52 | (POST "/hosts" {:keys [params role]} 53 | (if-let [validation-error (validate-params params host-validation-map)] 54 | (do 55 | (log-info (str "Invalid API POST to /hosts:\n" 56 | " Params: " params "\n" 57 | " Role: " role "\n" 58 | " Error: " validation-error)) 59 | (create-json-validation-error-response validation-error)) 60 | (let [host (models/find-or-create-host (:hostname params))] 61 | (models/add-host-to-role (:id host) (:id role)) 62 | (create-json-response host)))) 63 | 64 | (DELETE "/hosts/:hostname" {:keys [params role]} 65 | (if-let [host (models/get-host-by-hostname-in-role (:hostname params) (:id role))] 66 | (do 67 | (models/remove-host-from-role (:id host) (:id role)) 68 | {:status 204}) ; No content 69 | (create-json-error-response 404 "hostname not found in role"))) 70 | 71 | (POST "/snooze" {:keys [params role]} 72 | (let [snooze-duration (-> params :duration Long/parseLong) 73 | role-id (:id role) 74 | snooze-until (models/snooze-role role-id snooze-duration)] 75 | (create-json-response {:msg (snooze-message snooze-until)})))) 76 | 77 | (defroutes api-routes 78 | (context "/roles/:id" [] 79 | (wrap-roles-routes role-api-routes)) 80 | 81 | (PUT "/check_statuses/:id" {:keys [params body]} 82 | (let [body (slurp body) 83 | check-status-id (-> params :id Integer/parseInt)] 84 | (if (#{"paused" "enabled"} body) 85 | (do 86 | (k/update models/check-statuses 87 | (k/set-fields {:state body}) 88 | (k/where {:id check-status-id})) 89 | {:status 200}) 90 | {:status 400 :body "Invalid state."})))) 91 | -------------------------------------------------------------------------------- /src/lobos/migrations.clj: -------------------------------------------------------------------------------- 1 | (ns lobos.migrations 2 | "Database migrations using the Lobos framework." 3 | (:refer-clojure :exclude [alter drop bigint boolean char double float time]) 4 | (:use (lobos [migration :only [defmigration]] 5 | core schema config) 6 | [clojure.java.shell :only [sh]] 7 | ; Some of Korma's functions (like table) collide with lobos. 8 | [korma.db :only [transaction]] 9 | [korma.incubator.core :only [select with-object fields where join order group limit insert delete 10 | values set-fields subselect update exec-raw]]) 11 | (:require [watchman.models :as models])) 12 | 13 | (defmigration create-data-model-20130908 14 | (up [] 15 | (create db 16 | (table :roles 17 | (integer :id :auto-inc :primary-key) 18 | (text :name))) 19 | (create db 20 | (table :checks 21 | (integer :id :auto-inc :primary-key) 22 | (integer :role_id :not-null [:refer :roles :id]) 23 | (text :path :not-null) 24 | (text :nickname) 25 | (double :timeout :not-null (default 3)) 26 | ; How often to check, in seconds. 27 | (integer :interval :not-null (default 60)) 28 | (integer :max_retries :not-null (default 0)) 29 | (integer :expected_status_code :not-null (default 200)) 30 | (text :expected_response_contents))) 31 | (create db 32 | (table :hosts 33 | (integer :id :auto-inc :primary-key) 34 | (text :hostname :not-null) 35 | (text :nickname))) 36 | (create db 37 | (table :roles_hosts 38 | (integer :host_id :not-null [:refer :hosts :id]) 39 | (integer :role_id :not-null [:refer :roles :id])) 40 | (index :roles_hosts_unique_host_id_and_role_id [:host_id :role_id] :unique)) 41 | (create db 42 | (table :check_statuses 43 | (integer :id :auto-inc :primary-key) 44 | (integer :host_id :not-null [:refer :hosts :id]) 45 | (integer :check_id :not-null [:refer :checks :id]) 46 | (text :state :not-null (default "enabled")) 47 | (timestamp :last_checked_at) 48 | (integer :last_response_status_code) 49 | (text :last_response_body) 50 | ; down, up, or unknown. 51 | (text :status :not-null (default "unknown")) 52 | (index :check_statuses_unique_host_id_and_check_id [:host_id :check_id] :unique)))) 53 | (down [] 54 | (doseq [table-name [:check_statuses :roles_hosts :hosts :checks :roles]] 55 | (drop (table table-name))))) 56 | 57 | (defmigration add_last_response_content_type_to_check_statuses-20131128 58 | (up [] 59 | (alter :add (table :check_statuses (text :last_response_content_type)))) 60 | (down [] 61 | (alter :drop (table :check_statuses (column :last_response_content_type))))) 62 | 63 | (defmigration add-unique-hostname-index-20130124 64 | (up [] 65 | (create (index :hosts :hosts_unique_hostname [:hostname] :unique))) 66 | (down [] 67 | (drop (index :hosts :hosts_unique_hostname)))) 68 | 69 | (defmigration add-send-email-column-to-check-20140529 70 | (up [] 71 | (alter :add (table :checks (boolean :send_email :not-null (default true))))) 72 | (down [] 73 | (alter :drop (table :checks (column :send_email))))) 74 | 75 | (defmigration add-status-last-changed-at-column-to-check-statuses-20141212 76 | (up [] 77 | (alter :add (table :check_statuses (timestamp :status_last_changed_at)))) 78 | (down [] 79 | (alter :drop (table :check_statuses (column :status_last_changed_at))))) 80 | 81 | (defmigration add-email-column-to-roles-20141220 82 | (up [] 83 | (alter :add (table :roles (text :email)))) 84 | (down [] 85 | (alter :drop (table :roles (column :email))))) 86 | 87 | (defmigration add-webhooks-column-20150315 88 | (up [] 89 | (create db 90 | (table :webhooks 91 | (integer :id :auto-inc :primary-key) 92 | (text :url)))) 93 | (down [] 94 | (drop (table :webhooks)))) 95 | 96 | (defmigration add-snooze-column-20150421 97 | (up [] 98 | (exec-raw "ALTER TABLE roles ADD COLUMN snooze_until TIMESTAMP WITH TIME ZONE")) 99 | (down [] 100 | (exec-raw "ALTER TABLE roles DROP COLUMN snooze_until"))) 101 | -------------------------------------------------------------------------------- /src/watchman/utils.clj: -------------------------------------------------------------------------------- 1 | (ns watchman.utils 2 | (:require [clj-time.core :as time-core] 3 | [clj-time.coerce :as time-coerce] 4 | [clj-time.format :as time-format] 5 | [clojure.core.incubator :refer [-?>]] 6 | [clojure.java.io :refer [reader writer]]) 7 | (:import [java.net URL MalformedURLException] 8 | [java.io BufferedWriter PrintWriter StringWriter])) 9 | 10 | ; dev-logging controls whether we log basic request info and exceptions to stdout, for dev workflows. 11 | (def ^:private dev-logging (and (not= (System/getenv "RING_ENV") "production"))) 12 | 13 | (defn get-env-var 14 | "Returns the env var with the given name, throwing an exception if it's blank in production." 15 | ([variable-name] 16 | (get-env-var variable-name true)) 17 | ([variable-name fail-if-blank] 18 | (let [value (System/getenv variable-name)] 19 | (when (and (empty? value) (= (System/getenv "RING_ENV") "production")) 20 | (throw (Exception. (format "Env variable %s is blank." variable-name)))) 21 | value))) 22 | 23 | ; Example: 22 Oct, 2013 24 | (def time-formatter (time-format/formatter "d MMM, YYYY")) 25 | 26 | (defn friendly-timestamp-string 27 | "For the given time, returns N secs ago, N minutes ago, or the full timestamp." 28 | [t] 29 | (let [seconds-ago (int (-> (or t (time-core/now)) 30 | (time-core/interval (time-core/now)) 31 | (time-core/in-secs))) 32 | minutes-ago (int (/ seconds-ago 60)) 33 | hours-ago (int (/ minutes-ago 60)) 34 | days-ago (int (/ hours-ago 24))] 35 | (cond 36 | (< seconds-ago 60) (str seconds-ago "s ago") 37 | (< minutes-ago 60) (str minutes-ago "m ago") 38 | (< hours-ago 24) (str hours-ago "h ago") 39 | (< days-ago 15) (str days-ago " days ago") 40 | :else (time-format/unparse time-formatter t)))) 41 | 42 | ; sget and sget-in (originally safe-get and safe-get-in) were lifted from the old clojure-contrib map-utils: 43 | ; https://github.com/richhickey/clojure-contrib/blob/master/src/main/clojure/clojure/contrib/map_utils.clj 44 | (defn sget 45 | "Like get, but throws an exception if the key is not found." 46 | [m k] 47 | (if-let [pair (find m k)] 48 | (val pair) 49 | (throw (IllegalArgumentException. (format "Key %s not found in %s" k m))))) 50 | 51 | (defn sget-in 52 | "Like get-in, but throws an exception if any key is not found." 53 | [m ks] 54 | (reduce sget m ks)) 55 | 56 | (defn truncate-string [s length] 57 | (subs (str s) 0 (min length (count (str s))))) 58 | 59 | ; Taken from http://clojuredocs.org/clojure_contrib/clojure.contrib.seq/indexed. 60 | (defn indexed 61 | "Returns a lazy sequence of [index, item] pairs, where items come from coll and indexes count up from zero. 62 | (indexed '(a b c d)) => ([0 a] [1 b] [2 c] [3 d]) 63 | (indexed '(a b c d) '(x y)) => ([0 a x] [1 b y])" 64 | [& colls] 65 | (apply map vector (iterate inc 0) colls)) 66 | 67 | 68 | (defn- timestamp-now-millis 69 | "Return a string containing the timestamp of the current time, including 70 | milliseconds (e.g. '2013-08-14T13:54:46.960Z')." 71 | [] 72 | (time-format/unparse (:date-time time-format/formatters) (time-core/now))) 73 | 74 | (defn- dated-log-filename 75 | "Returns the filename, with today's date appended to it. E.g. 76 | (dated-log-filename 'info') => info-20131008.log" 77 | ([filename] 78 | (dated-log-filename filename (time-core/now))) 79 | ([filename date] 80 | (let [date-string (time-format/unparse (:basic-date time-format/formatters) date)] 81 | (str filename "-" date-string ".log")))) 82 | 83 | (defn- log-to-dated-file 84 | "Returns nil." 85 | [filename line] 86 | (let [filename (dated-log-filename filename)] 87 | (with-open [file (writer filename :append true)] 88 | (.write file (str line "\n"))) 89 | nil)) 90 | 91 | (defn- printable-exception [exception] 92 | (let [string-writer (StringWriter.)] 93 | (.printStackTrace exception (PrintWriter. string-writer)) 94 | (str string-writer))) 95 | 96 | (defn log-exception 97 | "Log the given exception to the exceptions log file in an unbuffered manner. Returns nil." 98 | [preface exception] 99 | (let [line (format "%s %s:\n%s" (timestamp-now-millis) preface (printable-exception exception))] 100 | (when dev-logging (println line)) 101 | (log-to-dated-file "log/exceptions" line))) 102 | 103 | (defn log-info 104 | "Log the given string to the info log file in an unbuffered manner. Returns nil." 105 | [message] 106 | (let [line (format "%s %s" (timestamp-now-millis) message)] 107 | (when dev-logging (println line)) 108 | (log-to-dated-file "log/info" line))) 109 | 110 | (defn validate-hostname 111 | "Verifies that a hostname is valid as defined by RFC 2396." 112 | [hostname] 113 | (try 114 | (let [url (URL. (str "http://" hostname))] 115 | (= (.getHost url) hostname)) 116 | (catch MalformedURLException e 117 | false))) 118 | 119 | (defn role-snoozed? 120 | "Determines if the specified role is still asleep as specified by a snooze. cur-time is provided as an 121 | argument so that this result can be made consistent with other timing related queries." 122 | ([role] 123 | (role-snoozed? role (time-core/now))) 124 | ([role cur-time] 125 | (when-let [snooze-until (-?> (:snooze_until role) 126 | time-coerce/to-date-time)] 127 | (time-core/before? cur-time snooze-until)))) 128 | 129 | (defn snooze-message 130 | "Given a Joda DateTime, returns a formatted message for the UI indicating until when this role is snoozed. 131 | Formatted string will look like: 'Snoozed until 04-21-15 23:38 PST8PDT'. Currently PST8PDT is hardcoded in 132 | for the timezone." 133 | [snooze-until] 134 | (let [tz (time-core/time-zone-for-id "PST8PDT") 135 | formatter (time-format/with-zone (time-format/formatter "MM-dd-yy HH:mm") tz) 136 | formatted-time (->> snooze-until 137 | time-coerce/to-date-time 138 | (time-format/unparse formatter))] 139 | (str "Snoozed until " formatted-time " " (.getID tz)))) 140 | -------------------------------------------------------------------------------- /resources/roles_edit.html: -------------------------------------------------------------------------------- 1 |
2 | 54 | 55 | 56 |
57 | Your changes have been saved. 58 |
59 | 60 |
61 | 62 |
63 | 64 | 65 | 66 |
67 | 68 | 69 | 70 |

(Optional, comma-separated. If empty, alerts will go to default address)

71 |
72 | 73 |

Checks

74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 88 | 89 | 94 | 95 | 98 | 99 | 100 | 103 | 104 | 105 | 106 | 107 | 112 | 113 | 114 | 115 | 116 | 119 | 120 | 121 | 122 | 123 | 124 | 125 |
PathNicknameHTTP
status code
TimeoutMax
retries
Send
email?
108 | 109 | 110 | 111 | 117 | 118 |
126 | 127 |

Hosts

128 | 129 | 130 | 131 | 132 | 133 | 134 | 139 | 140 | 141 | 142 | 147 | 148 | 149 | 150 | 151 | 152 | 153 |
Hostname e.g. server1.example.com
143 | 144 | 145 | 146 |
154 | 155 |
156 | 157 | 158 | 159 |

(in minutes)

160 |
161 | -------------------------------------------------------------------------------- /resources/public/css/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Style resets. 3 | */ 4 | * { 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | li { list-style-type: none; } 10 | 11 | /* 12 | * General styles 13 | */ 14 | a { color: #1987C1; } 15 | a:active { 16 | background-color: #1987C1; 17 | color: white; 18 | } 19 | 20 | h1 { margin: 16px 0; } 21 | 22 | h2 { 23 | margin-top: 20px; 24 | margin-bottom: 10px; 25 | font-weight: normal; 26 | } 27 | 28 | p { margin: 16px 0; } 29 | 30 | input[type=text] { 31 | width: 100%; 32 | padding: 5px 5px; 33 | } 34 | 35 | button, .button, input[type=submit] { 36 | display: inline-block; 37 | width: 180px; 38 | padding: 12px 16px; 39 | font-size: 16px; 40 | background-color: #ccc; 41 | background-color: #F6CC25; 42 | color: black; 43 | border: 0; 44 | border-radius:4px; 45 | box-shadow: 0px 3px 0px #999; 46 | text-decoration: none; 47 | box-sizing: border-box; 48 | text-align: center; 49 | height: 40px; 50 | } 51 | 52 | button:active, .button:active, input[type=submit]:active { 53 | position: relative; 54 | top: 2px; 55 | box-shadow: 0px 1px 0px #999; 56 | height: 38px; 57 | outline: none; 58 | } 59 | 60 | i[class^="icon-"] { text-decoration: none; } 61 | 62 | .example { 63 | font-size: 80%; 64 | color: #888; 65 | font-weight: normal; 66 | } 67 | 68 | /* 69 | * Display tables. 70 | */ 71 | table.display { 72 | border-collapse: collapse; 73 | width: 100%; 74 | } 75 | 76 | table.display th, table.display td { 77 | padding: 0 10px; 78 | padding-top: 10px; 79 | width: 40%; 80 | vertical-align: bottom; 81 | } 82 | 83 | table.display th { 84 | text-align: left; 85 | padding-bottom: 10px; 86 | border-bottom: 1px solid #ccc; 87 | } 88 | 89 | table.display td.check-path { 90 | width: 60%; 91 | } 92 | 93 | table.display th:first-of-type, table.display td:first-of-type { 94 | padding-left: 0; 95 | } 96 | 97 | table.display th:last-of-type, table.display td:last-of-type { 98 | text-align: right; 99 | } 100 | 101 | html { height: 100%; } 102 | body { 103 | background-color: #f9f9f9; 104 | font-family: "Helvetica Neue", "Lucida Grande", Helvetica, Arial, Verdana, sans-serif; 105 | min-height: 100%; 106 | display: flex; 107 | } 108 | 109 | header { 110 | display: block; 111 | padding: 40px; 112 | padding-bottom: 0; 113 | margin-bottom: 40px; 114 | width: 100%; 115 | } 116 | 117 | #inner-body { 118 | min-width: 800px; 119 | flex: 2; 120 | box-sizing: border-box; 121 | padding: 0 40px 100px 40px; 122 | } 123 | 124 | #watchman-text { 125 | position: absolute; 126 | padding: 0 30px; 127 | right: 160px; 128 | line-height: 160px; 129 | font-size: 120px; 130 | -webkit-transform-origin: top right; 131 | -webkit-transform: rotate(-90deg); 132 | -moz-transform-origin: top right; 133 | -moz-transform: rotate(-90deg); 134 | color: #F6CC25; /* Yellow */ 135 | font-family: Impact, "Helvetica Neue", "Lucida Grande", Helvetica, Arial, sans-serif; 136 | font-weight: bold; 137 | -moz-user-select: none; 138 | -webkit-user-select: none; 139 | -ms-user-select: none; 140 | } 141 | 142 | aside.side-column { 143 | flex: 1; 144 | /* max-width is set so that the middle column can expand further for larger displays. */ 145 | max-width: 300px; 146 | z-index: -1; 147 | } 148 | 149 | #black-edge { 150 | /* Make sure the black edge is always tall enough to contain watchman-text. */ 151 | min-height: 700px; 152 | position: relative; 153 | background-color: black; 154 | } 155 | 156 | nav li { display: inline-block; } 157 | nav li a { 158 | display: inline-block; 159 | font-weight: bold; 160 | font-size: 20px; 161 | margin-right: 20px; 162 | padding-bottom: 15px; 163 | text-decoration: none; 164 | color: black; 165 | } 166 | nav li a:active { 167 | color: #666; 168 | background: none; 169 | } 170 | nav li.selected a { border-bottom: 3px solid #F6CC25; } 171 | nav a:hover, nav li.selected a:hover { border-bottom: 3px solid black; } 172 | 173 | /* 174 | * Index page. 175 | */ 176 | .check-status .status a { display: block; } 177 | .check-status .up { background-color: #69D166; } 178 | .check-status .up a { color: black; } 179 | .check-status .down { background-color: #D15335; } 180 | .check-status .down a { color: yellow; } 181 | .check-status .unknown { background-color: #e2e2e2; } 182 | .check-status .status a i { display: none; } 183 | .check-status .up a i.icon-ok { display: inline; } 184 | .check-status .down a i.icon-exclamation { display: inline; } 185 | .check-status .unknown a i.icon-question { display: inline; } 186 | 187 | table#check-statuses td { 188 | border-bottom: 1px solid #ddd; 189 | padding: 10px; 190 | font-size: 14px; 191 | white-space: nowrap; 192 | } 193 | table#check-statuses td:first-of-type { padding-left: 0; } 194 | 195 | table td.host { width: 100%; } 196 | table#check-statuses .last-checked, table#check-statuses .status-last-changed { text-align: right; } 197 | 198 | table#check-statuses td.status { text-align: center; } 199 | table#check-statuses tr[data-state="paused"] a .icon-pause { display: none; } 200 | table#check-statuses tr[data-state="enabled"] a .icon-play { display: none; } 201 | table#check-statuses tr[data-state="paused"] .status { 202 | background-color: #ddd; 203 | color: #999; 204 | } 205 | 206 | table#check-statuses th.pause, table#check-statuses td.pause { border: 0; } 207 | table#check-statuses td.pause a { color: #aaa; } 208 | 209 | table#check-statuses tr.failure-message { background-color: #eee; } 210 | table#check-statuses tr.failure-message td { 211 | /* This is a workaround to prevent long failure messages from distorting the table's cell spacing. */ 212 | max-width: 1px; 213 | text-align: left; 214 | } 215 | /* This response body can be long and overflow the table. */ 216 | table#check-statuses tr.failure-message pre { 217 | max-width: 690px; 218 | overflow-x: scroll; 219 | } 220 | 221 | /* 222 | * Roles page. 223 | */ 224 | #roles-page li { margin: 8px 0; } 225 | 226 | /* 227 | * Roles-edit page 228 | */ 229 | #flash-message { 230 | padding: 10px; 231 | background-color: #FFEFC2; 232 | } 233 | #roles-edit-page th span.example { margin-left: 8px; } 234 | #roles-edit-page table.display td:last-of-type { vertical-align: middle; } 235 | 236 | #roles-edit-page a.add, #roles-edit-page a.remove { text-decoration: none; } 237 | #roles-edit-page a.remove { color: #cc3333; } 238 | #roles-edit-page a:hover { color: black; } 239 | #roles-edit-page a:active { color: white; } 240 | 241 | #roles-edit-page th { white-space:nowrap; } 242 | 243 | #roles-edit-page input[name="name"] { 244 | width: 200px; 245 | margin-left: 10px; 246 | } 247 | 248 | #roles-edit-page input.path { width: 100%; } 249 | 250 | #roles-edit-page input.hostname { width: 400px; } 251 | 252 | #roles-edit-page input.expected-status-code, #roles-edit-page input.timeout, 253 | 254 | #roles-edit-page input.max-retries { width: 40px; } 255 | 256 | #roles-edit-page input[name="email"] { 257 | width: 200px; 258 | margin: 15px 0 0 10px; 259 | } 260 | 261 | span#snooze-until, span.snooze-message { 262 | font-size: 80%; 263 | color: #A33333; 264 | font-weight: normal; 265 | } 266 | 267 | span.hidden { display: none; } 268 | 269 | #roles-edit-page input.snooze-duration { 270 | width: 200px; 271 | margin-left: 10px; 272 | } 273 | 274 | #roles-edit-page p.snooze-desc { 275 | position: relative; 276 | left: 195px; 277 | } 278 | 279 | form.update { margin-bottom: 20px; } 280 | 281 | .optional { margin: 8px 0 } 282 | 283 | #roles-edit-page td.check-send-email { text-align:center; vertical-align:middle; } 284 | -------------------------------------------------------------------------------- /src/watchman/models.clj: -------------------------------------------------------------------------------- 1 | (ns watchman.models 2 | (:require [clj-time.core :as time-core] 3 | [clj-time.coerce :as time-coerce] 4 | [clj-time.format :as time-format] 5 | [clojure.core.incubator :refer [-?>]] 6 | [clojure.string :as string] 7 | [korma.db :refer [transaction]] 8 | [korma.incubator.core :as k :refer [belongs-to defentity has-many many-to-many]] 9 | [watchman.utils :refer [sget sget-in role-snoozed?]] 10 | [korma.db :refer :all])) 11 | 12 | (defdb watchman-db (postgres {:host (or (System/getenv "WATCHMAN_DB_HOST") "localhost") 13 | :db (or (System/getenv "WATCHMAN _DB_NAME") "watchman") 14 | :user (or (System/getenv "WATCHMAN_DB_USER") (System/getenv "USER")) 15 | :password (or (System/getenv "WATCHMAN_DB_PASS") "")})) 16 | 17 | (declare check-statuses) 18 | 19 | (def underscorize 20 | "Takes a hyphenated keyword (or string) and returns an underscored keyword. 21 | Appends the optional suffix string arg if supplied." 22 | (memoize (fn [hyphenated-keyword & {:keys [suffix]}] 23 | (-> hyphenated-keyword name (string/replace "-" "_") (str suffix) keyword)))) 24 | 25 | (defmacro defentity2 26 | "Like defentity, but automatically sets the table name to the underscorized version of the entity name" 27 | [entity-var & body] 28 | (let [table-name (underscorize entity-var)] 29 | `(defentity ~entity-var 30 | (k/table ~table-name) 31 | ~@body))) 32 | 33 | ; A URL to check on one or more hosts. 34 | ; - path 35 | ; - nickname 36 | ; - timeout 37 | ; - interval: How frequently to run this check. Defaults to 60s. 38 | ; - max_retries: how many times to retry before considering the check a failure. 39 | ; - expected_status_code 40 | ; - expected_response_contents 41 | (defentity2 checks 42 | (has-many check-statuses {:fk :check_id})) 43 | 44 | ; A host to check, which can belong to one or more roles. 45 | ; - hostname 46 | ; - nickname 47 | (defentity2 hosts) 48 | 49 | ; The status of a check for a given host. 50 | ; - host_id 51 | ; - check_id 52 | ; - state: enabled or paused. 53 | ; - last_checked_at 54 | ; - last_response_status_code 55 | ; - last_response_body 56 | ; - status: either unknown (i.e. recently created), up or down 57 | (defentity2 check-statuses 58 | (belongs-to checks {:fk :check_id}) 59 | (belongs-to hosts {:fk :host_id})) 60 | 61 | ; A role is a set of checks and a set of hosts to apply them to. 62 | ; - name 63 | (defentity2 roles 64 | (has-many checks {:fk :role_id}) 65 | (many-to-many hosts :roles_hosts {:lfk :role_id :rfk :host_id})) 66 | 67 | ; A join table for roles-hosts. 68 | (defentity2 roles-hosts) 69 | 70 | ; A URL to which to post updates to check-statuses 71 | ; - url 72 | (defentity2 webhooks) 73 | 74 | (defn upsert 75 | "Updates rows which match the given where-map. If no row matches this map, insert one first. 76 | This isn't a truly robust or performant upsert, but it should be sufficient for our purposes." 77 | ([korma-entity where-map] 78 | (upsert korma-entity where-map {})) 79 | ([korma-entity where-map additional-fields] 80 | (let [row-values (merge where-map additional-fields)] 81 | (if (empty? (k/select korma-entity (k/where where-map))) 82 | (k/insert korma-entity (k/values row-values)) 83 | (k/update korma-entity 84 | (k/set-fields row-values) 85 | (k/where where-map)))))) 86 | 87 | (defn delete-check [check-id] 88 | (transaction 89 | (k/delete check-statuses (k/where {:check_id check-id})) 90 | (k/delete checks (k/where {:id check-id})))) 91 | 92 | (defn add-check-to-role [check-id role-id] 93 | (transaction 94 | (let [host-ids (->> (k/select roles-hosts (k/where {:role_id role-id})) 95 | (map :host_id))] 96 | (doseq [host-id host-ids] 97 | (upsert check-statuses {:host_id host-id :check_id check-id}))))) 98 | 99 | (defn add-host-to-role [host-id role-id] 100 | (transaction 101 | (upsert roles-hosts {:role_id role-id :host_id host-id}) 102 | ;; Create a check-status entry for every host+check pair. 103 | (let [check-ids (->> (k/select checks (k/where {:role_id role-id})) 104 | (map :id))] 105 | (doseq [check-id check-ids] 106 | (upsert check-statuses {:host_id host-id :check_id check-id}))))) 107 | 108 | (defn remove-host-from-role [host-id role-id] 109 | (transaction 110 | (let [check-ids (->> (k/select checks (k/where {:role_id role-id})) 111 | (map :id))] 112 | (k/delete check-statuses (k/where {:host_id host-id :check_id [in check-ids]})) 113 | (k/delete roles-hosts (k/where {:host_id host-id :role_id role-id}))))) 114 | 115 | (defn snooze-role 116 | "Snoozes a role for the given duration in minutes. Returns the snooze_until parameter set for the role." 117 | [role-id snooze-duration] 118 | (let [snooze-until (->> snooze-duration 119 | time-core/minutes 120 | (time-core/plus (time-core/now)) 121 | time-coerce/to-timestamp)] 122 | (k/update roles 123 | (k/set-fields {:snooze_until snooze-until}) 124 | (k/where {:id role-id})) 125 | snooze-until)) 126 | 127 | (defn get-check-statuses-with-hosts-and-checks 128 | "Returns a sequence of all check-status records in the database with their associated :hosts and :checks 129 | relations eagerly loaded." 130 | [] 131 | ; Korma.incubator's 'with-object' unfortunately does not eagerly load associations. 132 | ; See: https://github.com/korma/korma.incubator/issues/7 133 | (letfn [(in-memory-join [model foreign-key relation check-statuses] 134 | (let [ids (->> check-statuses (map #(sget % foreign-key)) set) 135 | id->object (->> (k/select model (k/where {:id [in ids]})) 136 | (reduce (fn [m obj] (assoc m (sget obj :id) obj)) {}))] 137 | (map (fn [check-status] 138 | (assoc check-status relation (sget id->object (sget check-status foreign-key)))) 139 | check-statuses)))] 140 | (->> (k/select check-statuses) 141 | (in-memory-join hosts :host_id :hosts) 142 | (in-memory-join checks :check_id :checks)))) 143 | 144 | (defn get-check-status-by-id [id] 145 | (first (k/select check-statuses 146 | (k/where {:id id}) 147 | (k/with-object hosts) 148 | (k/with-object checks)))) 149 | 150 | (defn get-role-by-id [id] 151 | (first (k/select roles 152 | (k/where {:id id}) 153 | (k/with-object hosts) 154 | (k/with-object checks)))) 155 | 156 | (defn get-host-by-id [id] 157 | (first (k/select hosts (k/where {:id id})))) 158 | 159 | (defn get-hosts-in-role [role-id] 160 | (k/select hosts 161 | (k/join roles-hosts (= :roles_hosts.host_id :id)) 162 | (k/where {:roles_hosts.role_id role-id}))) 163 | 164 | (defn get-host-by-hostname-in-role 165 | "Returns a host record by hostname iff it is assigned to the specified role." 166 | [hostname role-id] 167 | (first (k/select hosts 168 | (k/join roles-hosts (= :roles_hosts.host_id :id)) 169 | (k/where {:hostname hostname 170 | :roles_hosts.role_id role-id})))) 171 | 172 | (defn get-host-by-hostname [hostname] 173 | (first (k/select hosts (k/where {:hostname hostname})))) 174 | 175 | (defn get-check-display-name [check-status] 176 | (or (sget check-status :nickname) 177 | (sget check-status :path))) 178 | 179 | (defn extract-subdomain-from-hostname 180 | "Returns the subdomain of the given fully-qualified domain name. 181 | Given hostname subdomain1.subdomain2.example.com, returns subdomain1.subdomain2. 182 | Given example.com, returns example.com." 183 | [hostname] 184 | (let [parts (string/split hostname #"\.")] 185 | (if (< (count parts) 3) 186 | hostname 187 | (string/join "." (take (- (count parts) 2) parts))))) 188 | 189 | (defn get-host-display-name [host] 190 | (or (sget host :nickname) 191 | (-> (sget host :hostname) 192 | extract-subdomain-from-hostname))) 193 | 194 | (defn get-url-of-check-status 195 | "The URL (hostname plus path) that a check-status checks." 196 | [check-status] 197 | (str "http://" (sget-in check-status [:hosts :hostname]) (sget-in check-status [:checks :path]))) 198 | 199 | (defn get-webhooks [] 200 | (k/select webhooks)) 201 | 202 | (defn create-role [fields] 203 | (k/insert roles (k/values fields))) 204 | 205 | (defn create-check [fields] 206 | (k/insert checks (k/values fields))) 207 | 208 | (defn create-host [fields] 209 | (k/insert hosts (k/values fields))) 210 | 211 | (defn find-or-create-host [hostname] 212 | (or (first (k/select hosts 213 | (k/where {:hostname hostname}))) 214 | (k/insert hosts (k/values {:hostname hostname})))) 215 | 216 | (defn update-check-status [id fields] 217 | {:pre [(number? id)]} 218 | (k/update check-statuses 219 | (k/set-fields fields) 220 | (k/where {:id id}))) 221 | 222 | (defn ready-to-perform 223 | "True if enough time has elapsed since we last checked this alert and the alert's role isn't 224 | snoozed." 225 | [check-status] 226 | (let [cur-time (time-core/now) 227 | role-id (-> check-status :checks :role_id) 228 | last-checked-at (-?> (sget check-status :last_checked_at) 229 | time-coerce/to-date-time)] 230 | (when (not (role-snoozed? (get-role-by-id role-id) cur-time)) 231 | (or (nil? last-checked-at) 232 | (->> (sget-in check-status [:checks :interval]) 233 | time-core/secs 234 | (time-core/plus last-checked-at) 235 | (time-core/after? cur-time)))))) 236 | -------------------------------------------------------------------------------- /src/watchman/pinger.clj: -------------------------------------------------------------------------------- 1 | (ns watchman.pinger 2 | (:require [cheshire.core :as json] 3 | [clojure.core.incubator :refer [-?>]] 4 | [clj-time.core :as time-core] 5 | [clj-time.coerce :as time-coerce] 6 | [clj-time.format :as time-format] 7 | [clj-http.client :as http] 8 | [clojure.string :as string] 9 | [korma.incubator.core :as k] 10 | [overtone.at-at :as at-at] 11 | [watchman.models :as models] 12 | [net.cgrand.enlive-html :refer [content deftemplate do-> html-content set-attr substitute]] 13 | [watchman.utils :refer [get-env-var log-exception log-info sget sget-in truncate-string]] 14 | [postal.core :as postal]) 15 | (:import org.apache.http.conn.ConnectTimeoutException 16 | java.net.SocketTimeoutException)) 17 | 18 | (def log-emails-without-sending (= "true" (System/getenv "WATCHMAN_LOG_EMAILS_WITHOUT_SENDING"))) 19 | 20 | (def smtp-credentials {:user (get-env-var "WATCHMAN_SMTP_USERNAME") 21 | :pass (get-env-var "WATCHMAN_SMTP_PASSWORD") 22 | :host (or (get-env-var "WATCHMAN_SMTP_HOST") 23 | ; Default to Amazon's Simple Email Service. 24 | "email-smtp.us-east-1.amazonaws.com") 25 | :port 587 26 | :tls true}) 27 | 28 | (def from-email-address (get-env-var "WATCHMAN_FROM_EMAIL_ADDRESS")) 29 | (def to-email-address (get-env-var "WATCHMAN_TO_EMAIL_ADDRESS")) 30 | 31 | (def watchman-host (or (System/getenv "WATCHMAN_HOST") "localhost:8130")) 32 | 33 | (def polling-frequency-ms 5000) 34 | 35 | (def response-body-size-limit-chars 10000) 36 | 37 | (def at-at-pool (at-at/mk-pool)) 38 | 39 | (def checks-in-progress 40 | "A map check-status-id -> {:in-progress, :attempt-number}. This is a bookkeeping map, for handling retries." 41 | (atom {})) 42 | 43 | (defn- add-role-to-email-address 44 | "Adds the name of the role to the email address using this format: username+role@domain.com. This makes 45 | filtering emails for a given role easier." 46 | [role-name email-address] 47 | (let [[username domain] (string/split email-address #"@") 48 | ; Make sure the role name is suitable for being embedded in an email address. 49 | escaped-role-name (-> role-name 50 | (string/replace #"\s+" "_") 51 | (string/replace #"[^\w]" "") 52 | string/lower-case)] 53 | (format "%s+%s@%s" username escaped-role-name domain))) 54 | 55 | (deftemplate alert-email-html-template "alert_email.html" 56 | [check-status response-body] 57 | [:#check-url] (do-> (set-attr "href" (models/get-url-of-check-status check-status)) 58 | (content (models/get-url-of-check-status check-status))) 59 | [:#ssh-link] (set-attr "href" (format "http://%s/ssh_redirect?host_id=%s" watchman-host 60 | (sget check-status :host_id))) 61 | [:#status] (substitute (sget check-status :status)) 62 | [:#http-status] (substitute (str (sget check-status :last_response_status_code))) 63 | [:.unique-message-id] (content (->> (time-core/now) 64 | time-coerce/to-date-time 65 | (time-format/unparse (:date-time time-format/formatters)))) 66 | [:#additional-details] (if (= (sget check-status :status) "down") 67 | identity 68 | (substitute nil)) 69 | [:#response-body] (if (= (sget check-status :status) "up") 70 | (substitute nil) 71 | (html-content response-body))) 72 | 73 | (defn- escape-html-chars [s] 74 | (-> s (string/replace "&" "&") (string/replace "<" "<") (string/replace ">" ">"))) 75 | 76 | (defn- alert-email-html [check-status] 77 | (let [response-body (-> check-status 78 | (sget :last_response_body) 79 | str 80 | (truncate-string response-body-size-limit-chars)) 81 | response-body (if (= (sget check-status :last_response_content_type) "text/html") 82 | response-body 83 | (-> response-body escape-html-chars (string/replace "\n" "
")))] 84 | (alert-email-html-template check-status response-body))) 85 | 86 | (defn- alert-email-plaintext [check-status] 87 | (let [status (sget check-status :status) 88 | url (models/get-url-of-check-status check-status) 89 | up-template (string/join "\n" ["%s" 90 | "Status: %s"]) 91 | down-template (string/join "\n" ["%s" 92 | "Status: %s" 93 | "HTTP status code: %s" 94 | "Body:" 95 | "%s"])] 96 | (if (= status "up") 97 | (format up-template url status) 98 | (format down-template 99 | url 100 | status 101 | (sget check-status :last_response_status_code) 102 | (-> (sget check-status :last_response_body) (truncate-string response-body-size-limit-chars)))))) 103 | 104 | (defn send-email 105 | "Send an email describing the current state of a check-status." 106 | ; We commonly call this function from the REPL, so it's nice to have all needed information as arguments. 107 | [check-status from-email-address to-email-addresses smtp-credentials] 108 | (let [host (sget check-status :hosts) 109 | check (sget check-status :checks) 110 | check-status-id (sget check-status :id) 111 | role-name (-> (sget check :role_id) (models/get-role-by-id) (sget :name)) 112 | subject (format "%s %s" (models/get-host-display-name host) (models/get-check-display-name check)) 113 | html-body (string/join (alert-email-html check-status)) 114 | plaintext-body (alert-email-plaintext check-status) 115 | email-message {:from (add-role-to-email-address role-name from-email-address) 116 | :to to-email-addresses 117 | :subject subject 118 | :body [:alternative 119 | {:type "text/plain; charset=utf-8" :content plaintext-body} 120 | {:type "text/html; charset=utf-8" :content html-body}]}] 121 | (log-info (format "Emailing for check-status %s: %s %s" check-status-id 122 | (sget host :hostname) (sget check-status :status))) 123 | (if log-emails-without-sending 124 | (prn email-message) 125 | (let [result (postal/send-message smtp-credentials email-message)] 126 | (log-info (format "Email body check-status %s: %s" check-status-id email-message)) 127 | (when (not= (:error result) :SUCCESS) 128 | (log-info (format "Email for check-status %s failed to send:%s\nFull body\n:%s" check-status-id 129 | result email-message))))))) 130 | 131 | (defn- post-to-webhooks [check-status] 132 | (let [urls (map #(sget % :url) (models/get-webhooks))] 133 | (when (seq urls) 134 | (let [check-status-id (sget check-status :id) 135 | host-name (first (string/split watchman-host #":")) 136 | message (format "j:%s:%d|%s" 137 | host-name (System/currentTimeMillis) 138 | (json/generate-string check-status))] 139 | (doseq [url urls] 140 | (try 141 | (http/post url {:body message}) 142 | (log-info (format "Posted check-status %s to webhook %s" check-status-id url)) 143 | (catch Exception e 144 | (log-exception (format "Failed to post check-status %s to webhook %s" check-status-id url) e)))))))) 145 | 146 | (defn- has-remaining-attempts? [check-status] 147 | (<= (sget-in @checks-in-progress [(sget check-status :id) :attempt-number]) 148 | (sget-in check-status [:checks :max_retries]))) 149 | 150 | (defn- perform-http-request-for-check 151 | "Requests the URL of the given check, catches all exceptions, and returns a map containing {:status, :body}" 152 | [check-status] 153 | (let [url (models/get-url-of-check-status check-status) 154 | timeout (sget-in check-status [:checks :timeout])] 155 | (try 156 | (http/get url {:conn-timeout (-> timeout (* 1000) int) 157 | :socket-timeout (-> timeout (* 1000) int) 158 | ; Don't throw exceptions on 500 status codes. 159 | :throw-exceptions false}) 160 | (catch ConnectTimeoutException exception 161 | {:status nil :body (format "Connection to %s timed out after %ss." url timeout)}) 162 | (catch SocketTimeoutException exception 163 | {:status nil :body (format "No data read from socket to %s after %ss." url timeout)}) 164 | (catch Exception exception 165 | ; We can get a ConnectionException if the host exists but is not listening on the port, or if it's 166 | ; an unknown host. 167 | {:status nil :body (format "Error connecting to %s: %s" url exception)})))) 168 | 169 | (defn- strip-charset-from-content-type 170 | "Removes the charset from a content type HTTP header, e.g. 'text/html;charset=UTF-8' => 'text/html'" 171 | [content-type-header] 172 | (-> content-type-header (string/split #";") first)) 173 | 174 | (defn perform-check 175 | "Makes a synchronous HTTP request and updates the corresponding check-status DB object with the results." 176 | [check-status has-remaining-attempts] 177 | (let [check-status-id (sget check-status :id) 178 | check (sget check-status :checks) 179 | role-emails (when-let [emails-string (-> (sget check :role_id) (models/get-role-by-id) (sget :email))] 180 | (->> (string/split emails-string #",") 181 | (map string/trim))) 182 | host (sget-in check-status [:hosts :hostname]) 183 | response (perform-http-request-for-check check-status) 184 | is-up (= (:status response) (sget check :expected_status_code)) 185 | previous-status (sget check-status :status) 186 | new-status (if is-up "up" "down") 187 | current-timestamp (time-coerce/to-timestamp (time-core/now))] 188 | (log-info (format "Result for check-status %s: %s %s" check-status-id 189 | (models/get-url-of-check-status check-status) (:status response))) 190 | (when-not is-up 191 | (log-info (format "check-status %s body: %s" check-status-id 192 | (-> response :body string/trim (truncate-string 1000))))) 193 | (when (or (not= previous-status new-status) (nil? (sget check-status :status_last_changed_at))) 194 | (models/update-check-status check-status-id {:status_last_changed_at current-timestamp})) 195 | (if (or is-up (not has-remaining-attempts)) 196 | (do 197 | (models/update-check-status 198 | check-status-id 199 | {:last_checked_at current-timestamp 200 | :last_response_status_code (:status response) 201 | :last_response_content_type (-?> (get-in response [:headers "content-type"]) 202 | strip-charset-from-content-type) 203 | :last_response_body (-> response :body 204 | (truncate-string response-body-size-limit-chars)) 205 | :status new-status}) 206 | (when (and (not= new-status previous-status) (sget check :send_email)) 207 | (let [current-status (models/get-check-status-by-id check-status-id)] 208 | (send-email current-status 209 | from-email-address 210 | (or role-emails [to-email-address]) 211 | smtp-credentials) 212 | (post-to-webhooks current-status))) 213 | (swap! checks-in-progress dissoc check-status-id)) 214 | (do 215 | (models/update-check-status check-status-id {:last_checked_at current-timestamp}) 216 | (log-info (str "Will retry check-status id " check-status-id)))))) 217 | 218 | (defn- perform-check-in-background 219 | "The HTTP request and the response assertions are done inside of a future." 220 | [check-status] 221 | (let [check-status-id (sget check-status :id)] 222 | (swap! checks-in-progress (fn [old-value] 223 | (-> old-value 224 | (assoc-in [check-status-id :in-progress] true) 225 | (update-in [check-status-id :attempt-number] #(inc (or % 0)))))) 226 | (future 227 | (try 228 | (perform-check check-status (has-remaining-attempts? check-status)) 229 | (catch Exception exception 230 | (log-exception (str "Failed to perform check. check-status id: " check-status-id) exception)) 231 | (finally 232 | (swap! checks-in-progress assoc-in [check-status-id :in-progress] false)))))) 233 | 234 | (defn- perform-eligible-checks 235 | "Performs all checks which are scheduled to run." 236 | [] 237 | (let [check-statuses (k/select models/check-statuses 238 | (k/where {:state "enabled"}) 239 | (k/with-object models/hosts) 240 | (k/with-object models/checks))] 241 | (doseq [check-status check-statuses] 242 | (when (and (not (get-in @checks-in-progress [(sget check-status :id) :in-progress])) 243 | (models/ready-to-perform check-status)) 244 | (perform-check-in-background check-status))))) 245 | 246 | (defn start-periodic-polling [] 247 | ; NOTE(philc): For some reason, at-at's jobs do not run from within nREPL. 248 | (at-at/every polling-frequency-ms 249 | #(try 250 | (perform-eligible-checks) 251 | (catch Exception exception 252 | (log-exception exception))) 253 | at-at-pool)) 254 | 255 | (defn- stop-periodic-polling [] 256 | (at-at/stop-and-reset-pool! at-at-pool)) 257 | -------------------------------------------------------------------------------- /src/watchman/handler.clj: -------------------------------------------------------------------------------- 1 | (ns watchman.handler 2 | (:gen-class) 3 | (:use compojure.core 4 | [clojure.core.incubator :only [-?> -?>>]] 5 | [ring.middleware.session.cookie :only [cookie-store]] 6 | [ring.middleware.stacktrace] 7 | [ring.middleware.multipart-params :only [wrap-multipart-params]] 8 | [ring.adapter.jetty :only [run-jetty]]) 9 | (:require [compojure.handler :as handler] 10 | [watchman.api-v1-handler :as api-handler] 11 | [cemerick.friend :as friend] 12 | (cemerick.friend [workflows :as friend-workflows] 13 | [credentials :as friend-creds]) 14 | [net.cgrand.enlive-html :refer :all] 15 | [clj-time.core :as time-core] 16 | [clj-time.coerce :as time-coerce] 17 | [clj-time.format :as time-format] 18 | [clojure.java.io :as clj-io] 19 | clojure.walk 20 | [compojure.route :as route] 21 | [clojure.string :as string] 22 | [watchman.utils :refer [friendly-timestamp-string indexed sget sget-in role-snoozed? snooze-message]] 23 | [ring.util.response :refer [redirect]] 24 | [korma.incubator.core :as k] 25 | [korma.db :as korma-db] 26 | [watchtower.core :as watcher] 27 | [watchman.pinger :as pinger] 28 | [watchman.models :as models])) 29 | 30 | (def http-auth-username (or (System/getenv "WATCHMAN_USERNAME") "watchman")) 31 | (def http-auth-password (or (System/getenv "WATCHMAN_PASSWORD") "password")) 32 | (def authorized-users {http-auth-username {:username http-auth-username 33 | :password (-> http-auth-password friend-creds/hash-bcrypt) 34 | :roles #{::user}}}) 35 | 36 | (defn- get-check-status-failure-message 37 | "A message describing the check's failure. nil if the alert isn't in state 'down'." 38 | [check-status] 39 | (when (= (sget check-status :status) "down") 40 | (format "Status code: %s\nBody:\n%s" 41 | (sget check-status :last_response_status_code) 42 | (sget check-status :last_response_body)))) 43 | 44 | (defsnippet index-page "index.html" [:#index-page] 45 | [check-statuses] 46 | [:tr.check-status] (clone-for [check-status check-statuses] 47 | [:tr] (do-> (set-attr :data-check-status-id (sget check-status :id)) 48 | (set-attr :data-state (sget check-status :state))) 49 | [:.host :a] (do-> 50 | (set-attr :href (str "/roles/" (sget-in check-status [:checks :role_id]))) 51 | (content (models/get-host-display-name (sget check-status :hosts)))) 52 | [:.name] (content (models/get-check-display-name (sget check-status :checks))) 53 | [:.last-checked] (content (-> (sget check-status :last_checked_at) 54 | time-coerce/to-date-time 55 | friendly-timestamp-string)) 56 | [:.status-last-changed] (content (-> (sget check-status :status_last_changed_at) 57 | time-coerce/to-date-time 58 | friendly-timestamp-string)) 59 | [:.status] (add-class (sget check-status :status)) 60 | [:.failure-reason] (content (get-check-status-failure-message check-status)))) 61 | 62 | (defsnippet roles-page "roles.html" [:#roles-page] 63 | [roles] 64 | [:li] (clone-for [role roles] 65 | [:a] (do-> (content (:name role)) 66 | (set-attr :href (str "/roles/" (:id role)))) 67 | [:span.snooze-message] (if (role-snoozed? role) 68 | (->> (:snooze_until role) 69 | snooze-message 70 | content) 71 | (add-class "hidden")))) 72 | 73 | 74 | 75 | ; The editing UI for a role and its associated checks and hosts. 76 | ; - role: nil if this page is to render a new, unsaved role." 77 | (defsnippet roles-edit-page "roles_edit.html" [:#roles-edit-page] 78 | [role flash-message] 79 | [[:input (attr= :name "id")]] (set-attr :value (:id role)) 80 | [[:input (attr= :name "name")]] (set-attr :value (:name role)) 81 | [[:input (attr= :name "email")]] (set-attr :value (:email role)) 82 | [:span#snooze-until] (if (role-snoozed? role) 83 | (->> (:snooze_until role) 84 | snooze-message 85 | content) 86 | (add-class "hidden")) 87 | 88 | [:#flash-message] (if flash-message (content flash-message) (substitute nil)) 89 | ; I sense a missing abstraction. 90 | [:tr.check] (clone-for [[i check] (->> role :checks (sort-by models/get-check-display-name) indexed)] 91 | [:input.id] (do-> (set-attr :value (sget check :id)) 92 | (set-attr :name (format "checks[%s][id]" i))) 93 | [:input.deleted] (set-attr :name (format "checks[%s][deleted]" i)) 94 | [:input.path] (do-> (set-attr :value (sget check :path)) 95 | (set-attr :name (format "checks[%s][path]" i))) 96 | [:input.nickname] (do-> (set-attr :value (sget check :nickname)) 97 | (set-attr :name (format "checks[%s][nickname]" i))) 98 | [:input.expected-status-code] (do-> 99 | (set-attr :value (sget check :expected_status_code)) 100 | (set-attr :name (format "checks[%s][expected_status_code]" i))) 101 | [:input.timeout] (do-> (set-attr :value (:timeout check)) 102 | (set-attr :name (format "checks[%s][timeout]" i))) 103 | [:input.max-retries] (do-> (set-attr :value (sget check :max_retries)) 104 | (set-attr :name (format "checks[%s][max_retries]" i))) 105 | [:input.send-email] (do-> (set-attr :name (format "checks[%s][send_email]" i)) 106 | (if (sget check :send_email) (set-attr :checked "true") identity))) 107 | [:tr.host] (clone-for [[i host] (->> role :hosts (sort-by :hostname) indexed)] 108 | [:input.id] (do-> (set-attr :value (sget host :id)) 109 | (set-attr :name (format "hosts[%s][id]" i))) 110 | [:input.deleted] (set-attr :name (format "hosts[%s][deleted]" i)) 111 | [:input.hostname] (do-> (set-attr :name (format "hosts[%s][hostname]" i)) 112 | (set-attr :value (sget host :hostname)))) 113 | [:form.snooze] (set-attr :action (str "/api/v1/roles/" (:id role) "/snooze"))) 114 | 115 | 116 | (deftemplate layout "layout.html" 117 | [body nav] 118 | [:#page-content] (content body) 119 | [:nav] (content nav)) 120 | 121 | (defsnippet nav "layout.html" [:nav] 122 | [selected-section] 123 | [(->> selected-section name (str "li.") keyword)] (add-class "selected")) 124 | 125 | (defn prune-empty-strings 126 | "Recurisely removes key/value pairs from the map where the value is an empty string. Useful for exclusing 127 | blank HTTP form params." 128 | [m] 129 | (clojure.walk/postwalk (fn [form] 130 | (if (map? form) 131 | (into {} (filter #(not= (val %) "") form)) 132 | form)) 133 | m)) 134 | 135 | ; TODO(caleb): This could ensure the path will parse properly. See issue #7. 136 | (defn sanitize-path 137 | "Sanitize a user-supplied path. Right now, just adds on a leading / if none exists. A nil path is coerced to 138 | an empty path (/)." 139 | [path] 140 | (if path 141 | (if (re-find #"^/" path) path (str "/" path)) 142 | "/")) 143 | 144 | (defn save-role-from-params 145 | "Saves a role based on the given params. The params include the list of hosts and checks to associate 146 | with this role." 147 | ; TODO(philc): Move all Korma queries in this fn into models.clj. 148 | [params] 149 | (let [role-id (-> (sget params :id) Integer/parseInt) 150 | checks (-> params :checks vals) 151 | hostnames (->> params :hosts vals 152 | (filter #(not= "true" (:deleted %))) 153 | (map :hostname) set) 154 | existing-hosts (models/get-hosts-in-role role-id) 155 | serialize-to-db-from-params 156 | (fn [object insert-fn update-fn delete-fn] 157 | (let [object-id (-?> object :id Integer/parseInt) 158 | is-deleted (= (:deleted object) "true")] 159 | (if object-id 160 | (if is-deleted (delete-fn) (update-fn)) 161 | (when-not is-deleted (insert-fn)))))] 162 | (k/update models/roles 163 | (k/set-fields {:name (sget params :name) 164 | :email (:email params)}) 165 | (k/where {:id role-id})) 166 | (doseq [check checks] 167 | (let [check-id (-?> check :id Integer/parseInt) 168 | path (sanitize-path (:path check)) 169 | check-db-fields {:path path 170 | :nickname (:nickname check) 171 | :expected_status_code (-?> check :expected_status_code Integer/parseInt) 172 | :timeout (-?> check :timeout Double/parseDouble) 173 | :role_id role-id 174 | :max_retries (-?> check :max_retries Integer/parseInt) 175 | :send_email (boolean (:send_email check))}] 176 | (serialize-to-db-from-params check 177 | #(let [check-id (-> (k/insert models/checks (k/values check-db-fields)) 178 | (sget :id))] 179 | (models/add-check-to-role check-id role-id)) 180 | #(k/update models/checks 181 | (k/set-fields check-db-fields) 182 | (k/where {:id check-id})) 183 | #(models/delete-check check-id)))) 184 | ; First remove all hosts with hostnames that didn't appear in the params, then add each specified host to 185 | ; the role. 186 | (let [removed-hosts (remove #(contains? hostnames (sget % :hostname)) existing-hosts)] 187 | (doseq [host removed-hosts] 188 | (models/remove-host-from-role (sget host :id) role-id) 189 | ; If this host belongs to no other roles, go ahead and delete it. 190 | (when (empty? (k/select models/roles-hosts (k/where {:host_id (sget host :id)}))) 191 | (k/delete models/hosts (k/where {:id (sget host :id)})))) 192 | (doseq [hostname hostnames] 193 | (let [host-record (models/find-or-create-host hostname)] 194 | (models/add-host-to-role (sget host-record :id) role-id)))))) 195 | 196 | (defroutes app-routes 197 | (context "/api/v1" [] api-handler/api-routes) 198 | 199 | (GET "/" {:keys [params]} 200 | (let [sort-key-fn (if (= (:order params) "hosts") 201 | #(vector (sget-in % [:hosts :hostname]) (sget-in % [:checks :path])) 202 | ; The desired sort order is down > paused > up. This sort fn logic leverages the fact 203 | ; that the state and status names sort lexically in that order. 204 | #(vector (if (= (sget % :state) "enabled") 205 | (sget % :status) 206 | (sget % :state)) 207 | (sget-in % [:hosts :hostname]) (sget-in % [:checks :path]))) 208 | check-statuses (->> (models/get-check-statuses-with-hosts-and-checks) 209 | (sort-by sort-key-fn))] 210 | (layout (index-page check-statuses) (nav :overview)))) 211 | 212 | (GET "/alertz" [] 213 | ; TODO(philc): Alert if we've seen recent exceptions. 214 | ; Make sure we can talk to the DB. 215 | (if (first (k/exec-raw "select 1 from roles" :results)) 216 | "Healthy\n" 217 | {:status 500 :body "Unable to talk to the database.\n"})) 218 | 219 | (GET "/roles" [] 220 | (let [roles (k/select models/roles (k/order :name))] 221 | (layout (roles-page roles) (nav :roles-edit)))) 222 | 223 | (GET "/roles/new" [] 224 | (layout (roles-edit-page nil nil) (nav :roles-edit))) 225 | 226 | (POST "/roles/new" {:keys [params]} 227 | (let [params (prune-empty-strings params) 228 | role-id (-> (select-keys params [:name]) models/create-role (sget :id))] 229 | (save-role-from-params (assoc params :id (str role-id))) 230 | (redirect (str "/roles/" role-id)))) 231 | 232 | (GET "/roles/:id" [id] 233 | (if-let [role (models/get-role-by-id (Integer/parseInt id))] 234 | (layout (roles-edit-page role nil) (nav :roles-edit)) 235 | {:status 404 :body "Role not found."})) 236 | 237 | ; Redirects the user to ssh://host. We have this redirect because we can't embed links with the ssh protocol 238 | ; in the body of Gmail emails. 239 | (GET "/ssh_redirect" [host_id] 240 | (if-let [host (models/get-host-by-id (Integer/parseInt host_id))] 241 | (redirect (str "ssh://" (sget host :hostname))) 242 | {:status 404 :body "Host not found."})) 243 | 244 | (POST "/roles/:id" {:keys [params]} 245 | (let [params (prune-empty-strings params) 246 | role-id (Integer/parseInt (:id params))] 247 | (if-let [role (models/get-role-by-id role-id)] 248 | (do (save-role-from-params params) 249 | (layout (roles-edit-page (models/get-role-by-id role-id) "Changes accepted.") (nav :roles-edit))) 250 | {:status 404}))) 251 | 252 | (route/resources "/") 253 | (route/not-found "Not Found")) 254 | 255 | (def app (-> app-routes 256 | (friend/wrap-authorize #{::user}) 257 | (friend/authenticate {:unauthenticated-handler #(friend-workflows/http-basic-deny "watchman" %) 258 | :workflows [(friend-workflows/http-basic 259 | :credential-fn 260 | #(friend-creds/bcrypt-credential-fn authorized-users %) 261 | :realm "watchman")]}) 262 | ring.middleware.stacktrace/wrap-stacktrace 263 | handler/site)) 264 | 265 | (defn- handle-template-file-change [files] 266 | (require 'watchman.handler :reload)) 267 | 268 | (defn init [] 269 | ; Reload this namespace and its templates when one of the templates changes. 270 | (when-not (= (System/getenv "RING_ENV") "production") 271 | (watcher/watcher ["resources"] 272 | (watcher/rate 50) ;; poll every 50ms 273 | (watcher/file-filter (watcher/extensions :html)) ;; filter by extensions 274 | (watcher/on-change handle-template-file-change))) 275 | (pinger/start-periodic-polling)) 276 | 277 | (defn -main [] 278 | "Starts a Jetty webserver with our Ring app. See here for other Jetty configuration options: 279 | http://ring-clojure.github.com/ring/ring.adapter.jetty.html" 280 | (let [port (Integer/parseInt (or (System/getenv "PORT") "8130"))] 281 | (init) 282 | (run-jetty app {:port port}))) 283 | -------------------------------------------------------------------------------- /resources/public/css/vendor/font-awesome.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 3.2.1 3 | * the iconic font designed for Bootstrap 4 | * ------------------------------------------------------------------------------ 5 | * The full suite of pictographic icons, examples, and documentation can be 6 | * found at http://fontawesome.io. Stay up to date on Twitter at 7 | * http://twitter.com/fontawesome. 8 | * 9 | * License 10 | * ------------------------------------------------------------------------------ 11 | * - The Font Awesome font is licensed under SIL OFL 1.1 - 12 | * http://scripts.sil.org/OFL 13 | * - Font Awesome CSS, LESS, and SASS files are licensed under MIT License - 14 | * http://opensource.org/licenses/mit-license.html 15 | * - Font Awesome documentation licensed under CC BY 3.0 - 16 | * http://creativecommons.org/licenses/by/3.0/ 17 | * - Attribution is no longer required in Font Awesome 3.0, but much appreciated: 18 | * "Font Awesome by Dave Gandy - http://fontawesome.io" 19 | * 20 | * Author - Dave Gandy 21 | * ------------------------------------------------------------------------------ 22 | * Email: dave@fontawesome.io 23 | * Twitter: http://twitter.com/davegandy 24 | * Work: Lead Product Designer @ Kyruus - http://kyruus.com 25 | */ 26 | /* FONT PATH 27 | * -------------------------- */ 28 | @font-face { 29 | font-family: 'FontAwesome'; 30 | src: url('/font/fontawesome-webfont.eot?v=3.2.1'); 31 | src: url('/font/fontawesome-webfont.eot?#iefix&v=3.2.1') format('embedded-opentype'), url('/font/fontawesome-webfont.woff?v=3.2.1') format('woff'), url('/font/fontawesome-webfont.ttf?v=3.2.1') format('truetype'), url('/font/fontawesome-webfont.svg#fontawesomeregular?v=3.2.1') format('svg'); 32 | font-weight: normal; 33 | font-style: normal; 34 | } 35 | /* FONT AWESOME CORE 36 | * -------------------------- */ 37 | [class^="icon-"], 38 | [class*=" icon-"] { 39 | font-family: FontAwesome; 40 | font-weight: normal; 41 | font-style: normal; 42 | text-decoration: inherit; 43 | -webkit-font-smoothing: antialiased; 44 | *margin-right: .3em; 45 | } 46 | [class^="icon-"]:before, 47 | [class*=" icon-"]:before { 48 | text-decoration: inherit; 49 | display: inline-block; 50 | speak: none; 51 | } 52 | /* makes the font 33% larger relative to the icon container */ 53 | .icon-large:before { 54 | vertical-align: -10%; 55 | font-size: 1.3333333333333333em; 56 | } 57 | /* makes sure icons active on rollover in links */ 58 | a [class^="icon-"], 59 | a [class*=" icon-"] { 60 | display: inline; 61 | } 62 | /* increased font size for icon-large */ 63 | [class^="icon-"].icon-fixed-width, 64 | [class*=" icon-"].icon-fixed-width { 65 | display: inline-block; 66 | width: 1.1428571428571428em; 67 | text-align: right; 68 | padding-right: 0.2857142857142857em; 69 | } 70 | [class^="icon-"].icon-fixed-width.icon-large, 71 | [class*=" icon-"].icon-fixed-width.icon-large { 72 | width: 1.4285714285714286em; 73 | } 74 | .icons-ul { 75 | margin-left: 2.142857142857143em; 76 | list-style-type: none; 77 | } 78 | .icons-ul > li { 79 | position: relative; 80 | } 81 | .icons-ul .icon-li { 82 | position: absolute; 83 | left: -2.142857142857143em; 84 | width: 2.142857142857143em; 85 | text-align: center; 86 | line-height: inherit; 87 | } 88 | [class^="icon-"].hide, 89 | [class*=" icon-"].hide { 90 | display: none; 91 | } 92 | .icon-muted { 93 | color: #eeeeee; 94 | } 95 | .icon-light { 96 | color: #ffffff; 97 | } 98 | .icon-dark { 99 | color: #333333; 100 | } 101 | .icon-border { 102 | border: solid 1px #eeeeee; 103 | padding: .2em .25em .15em; 104 | -webkit-border-radius: 3px; 105 | -moz-border-radius: 3px; 106 | border-radius: 3px; 107 | } 108 | .icon-2x { 109 | font-size: 2em; 110 | } 111 | .icon-2x.icon-border { 112 | border-width: 2px; 113 | -webkit-border-radius: 4px; 114 | -moz-border-radius: 4px; 115 | border-radius: 4px; 116 | } 117 | .icon-3x { 118 | font-size: 3em; 119 | } 120 | .icon-3x.icon-border { 121 | border-width: 3px; 122 | -webkit-border-radius: 5px; 123 | -moz-border-radius: 5px; 124 | border-radius: 5px; 125 | } 126 | .icon-4x { 127 | font-size: 4em; 128 | } 129 | .icon-4x.icon-border { 130 | border-width: 4px; 131 | -webkit-border-radius: 6px; 132 | -moz-border-radius: 6px; 133 | border-radius: 6px; 134 | } 135 | .icon-5x { 136 | font-size: 5em; 137 | } 138 | .icon-5x.icon-border { 139 | border-width: 5px; 140 | -webkit-border-radius: 7px; 141 | -moz-border-radius: 7px; 142 | border-radius: 7px; 143 | } 144 | .pull-right { 145 | float: right; 146 | } 147 | .pull-left { 148 | float: left; 149 | } 150 | [class^="icon-"].pull-left, 151 | [class*=" icon-"].pull-left { 152 | margin-right: .3em; 153 | } 154 | [class^="icon-"].pull-right, 155 | [class*=" icon-"].pull-right { 156 | margin-left: .3em; 157 | } 158 | /* BOOTSTRAP SPECIFIC CLASSES 159 | * -------------------------- */ 160 | /* Bootstrap 2.0 sprites.less reset */ 161 | [class^="icon-"], 162 | [class*=" icon-"] { 163 | display: inline; 164 | width: auto; 165 | height: auto; 166 | line-height: normal; 167 | vertical-align: baseline; 168 | background-image: none; 169 | background-position: 0% 0%; 170 | background-repeat: repeat; 171 | margin-top: 0; 172 | } 173 | /* more sprites.less reset */ 174 | .icon-white, 175 | .nav-pills > .active > a > [class^="icon-"], 176 | .nav-pills > .active > a > [class*=" icon-"], 177 | .nav-list > .active > a > [class^="icon-"], 178 | .nav-list > .active > a > [class*=" icon-"], 179 | .navbar-inverse .nav > .active > a > [class^="icon-"], 180 | .navbar-inverse .nav > .active > a > [class*=" icon-"], 181 | .dropdown-menu > li > a:hover > [class^="icon-"], 182 | .dropdown-menu > li > a:hover > [class*=" icon-"], 183 | .dropdown-menu > .active > a > [class^="icon-"], 184 | .dropdown-menu > .active > a > [class*=" icon-"], 185 | .dropdown-submenu:hover > a > [class^="icon-"], 186 | .dropdown-submenu:hover > a > [class*=" icon-"] { 187 | background-image: none; 188 | } 189 | /* keeps Bootstrap styles with and without icons the same */ 190 | .btn [class^="icon-"].icon-large, 191 | .nav [class^="icon-"].icon-large, 192 | .btn [class*=" icon-"].icon-large, 193 | .nav [class*=" icon-"].icon-large { 194 | line-height: .9em; 195 | } 196 | .btn [class^="icon-"].icon-spin, 197 | .nav [class^="icon-"].icon-spin, 198 | .btn [class*=" icon-"].icon-spin, 199 | .nav [class*=" icon-"].icon-spin { 200 | display: inline-block; 201 | } 202 | .nav-tabs [class^="icon-"], 203 | .nav-pills [class^="icon-"], 204 | .nav-tabs [class*=" icon-"], 205 | .nav-pills [class*=" icon-"], 206 | .nav-tabs [class^="icon-"].icon-large, 207 | .nav-pills [class^="icon-"].icon-large, 208 | .nav-tabs [class*=" icon-"].icon-large, 209 | .nav-pills [class*=" icon-"].icon-large { 210 | line-height: .9em; 211 | } 212 | .btn [class^="icon-"].pull-left.icon-2x, 213 | .btn [class*=" icon-"].pull-left.icon-2x, 214 | .btn [class^="icon-"].pull-right.icon-2x, 215 | .btn [class*=" icon-"].pull-right.icon-2x { 216 | margin-top: .18em; 217 | } 218 | .btn [class^="icon-"].icon-spin.icon-large, 219 | .btn [class*=" icon-"].icon-spin.icon-large { 220 | line-height: .8em; 221 | } 222 | .btn.btn-small [class^="icon-"].pull-left.icon-2x, 223 | .btn.btn-small [class*=" icon-"].pull-left.icon-2x, 224 | .btn.btn-small [class^="icon-"].pull-right.icon-2x, 225 | .btn.btn-small [class*=" icon-"].pull-right.icon-2x { 226 | margin-top: .25em; 227 | } 228 | .btn.btn-large [class^="icon-"], 229 | .btn.btn-large [class*=" icon-"] { 230 | margin-top: 0; 231 | } 232 | .btn.btn-large [class^="icon-"].pull-left.icon-2x, 233 | .btn.btn-large [class*=" icon-"].pull-left.icon-2x, 234 | .btn.btn-large [class^="icon-"].pull-right.icon-2x, 235 | .btn.btn-large [class*=" icon-"].pull-right.icon-2x { 236 | margin-top: .05em; 237 | } 238 | .btn.btn-large [class^="icon-"].pull-left.icon-2x, 239 | .btn.btn-large [class*=" icon-"].pull-left.icon-2x { 240 | margin-right: .2em; 241 | } 242 | .btn.btn-large [class^="icon-"].pull-right.icon-2x, 243 | .btn.btn-large [class*=" icon-"].pull-right.icon-2x { 244 | margin-left: .2em; 245 | } 246 | /* Fixes alignment in nav lists */ 247 | .nav-list [class^="icon-"], 248 | .nav-list [class*=" icon-"] { 249 | line-height: inherit; 250 | } 251 | /* EXTRAS 252 | * -------------------------- */ 253 | /* Stacked and layered icon */ 254 | .icon-stack { 255 | position: relative; 256 | display: inline-block; 257 | width: 2em; 258 | height: 2em; 259 | line-height: 2em; 260 | vertical-align: -35%; 261 | } 262 | .icon-stack [class^="icon-"], 263 | .icon-stack [class*=" icon-"] { 264 | display: block; 265 | text-align: center; 266 | position: absolute; 267 | width: 100%; 268 | height: 100%; 269 | font-size: 1em; 270 | line-height: inherit; 271 | *line-height: 2em; 272 | } 273 | .icon-stack .icon-stack-base { 274 | font-size: 2em; 275 | *line-height: 1em; 276 | } 277 | /* Animated rotating icon */ 278 | .icon-spin { 279 | display: inline-block; 280 | -moz-animation: spin 2s infinite linear; 281 | -o-animation: spin 2s infinite linear; 282 | -webkit-animation: spin 2s infinite linear; 283 | animation: spin 2s infinite linear; 284 | } 285 | /* Prevent stack and spinners from being taken inline when inside a link */ 286 | a .icon-stack, 287 | a .icon-spin { 288 | display: inline-block; 289 | text-decoration: none; 290 | } 291 | @-moz-keyframes spin { 292 | 0% { 293 | -moz-transform: rotate(0deg); 294 | } 295 | 100% { 296 | -moz-transform: rotate(359deg); 297 | } 298 | } 299 | @-webkit-keyframes spin { 300 | 0% { 301 | -webkit-transform: rotate(0deg); 302 | } 303 | 100% { 304 | -webkit-transform: rotate(359deg); 305 | } 306 | } 307 | @-o-keyframes spin { 308 | 0% { 309 | -o-transform: rotate(0deg); 310 | } 311 | 100% { 312 | -o-transform: rotate(359deg); 313 | } 314 | } 315 | @-ms-keyframes spin { 316 | 0% { 317 | -ms-transform: rotate(0deg); 318 | } 319 | 100% { 320 | -ms-transform: rotate(359deg); 321 | } 322 | } 323 | @keyframes spin { 324 | 0% { 325 | transform: rotate(0deg); 326 | } 327 | 100% { 328 | transform: rotate(359deg); 329 | } 330 | } 331 | /* Icon rotations and mirroring */ 332 | .icon-rotate-90:before { 333 | -webkit-transform: rotate(90deg); 334 | -moz-transform: rotate(90deg); 335 | -ms-transform: rotate(90deg); 336 | -o-transform: rotate(90deg); 337 | transform: rotate(90deg); 338 | filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); 339 | } 340 | .icon-rotate-180:before { 341 | -webkit-transform: rotate(180deg); 342 | -moz-transform: rotate(180deg); 343 | -ms-transform: rotate(180deg); 344 | -o-transform: rotate(180deg); 345 | transform: rotate(180deg); 346 | filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2); 347 | } 348 | .icon-rotate-270:before { 349 | -webkit-transform: rotate(270deg); 350 | -moz-transform: rotate(270deg); 351 | -ms-transform: rotate(270deg); 352 | -o-transform: rotate(270deg); 353 | transform: rotate(270deg); 354 | filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); 355 | } 356 | .icon-flip-horizontal:before { 357 | -webkit-transform: scale(-1, 1); 358 | -moz-transform: scale(-1, 1); 359 | -ms-transform: scale(-1, 1); 360 | -o-transform: scale(-1, 1); 361 | transform: scale(-1, 1); 362 | } 363 | .icon-flip-vertical:before { 364 | -webkit-transform: scale(1, -1); 365 | -moz-transform: scale(1, -1); 366 | -ms-transform: scale(1, -1); 367 | -o-transform: scale(1, -1); 368 | transform: scale(1, -1); 369 | } 370 | /* ensure rotation occurs inside anchor tags */ 371 | a .icon-rotate-90:before, 372 | a .icon-rotate-180:before, 373 | a .icon-rotate-270:before, 374 | a .icon-flip-horizontal:before, 375 | a .icon-flip-vertical:before { 376 | display: inline-block; 377 | } 378 | /* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen 379 | readers do not read off random characters that represent icons */ 380 | .icon-glass:before { 381 | content: "\f000"; 382 | } 383 | .icon-music:before { 384 | content: "\f001"; 385 | } 386 | .icon-search:before { 387 | content: "\f002"; 388 | } 389 | .icon-envelope-alt:before { 390 | content: "\f003"; 391 | } 392 | .icon-heart:before { 393 | content: "\f004"; 394 | } 395 | .icon-star:before { 396 | content: "\f005"; 397 | } 398 | .icon-star-empty:before { 399 | content: "\f006"; 400 | } 401 | .icon-user:before { 402 | content: "\f007"; 403 | } 404 | .icon-film:before { 405 | content: "\f008"; 406 | } 407 | .icon-th-large:before { 408 | content: "\f009"; 409 | } 410 | .icon-th:before { 411 | content: "\f00a"; 412 | } 413 | .icon-th-list:before { 414 | content: "\f00b"; 415 | } 416 | .icon-ok:before { 417 | content: "\f00c"; 418 | } 419 | .icon-remove:before { 420 | content: "\f00d"; 421 | } 422 | .icon-zoom-in:before { 423 | content: "\f00e"; 424 | } 425 | .icon-zoom-out:before { 426 | content: "\f010"; 427 | } 428 | .icon-power-off:before, 429 | .icon-off:before { 430 | content: "\f011"; 431 | } 432 | .icon-signal:before { 433 | content: "\f012"; 434 | } 435 | .icon-gear:before, 436 | .icon-cog:before { 437 | content: "\f013"; 438 | } 439 | .icon-trash:before { 440 | content: "\f014"; 441 | } 442 | .icon-home:before { 443 | content: "\f015"; 444 | } 445 | .icon-file-alt:before { 446 | content: "\f016"; 447 | } 448 | .icon-time:before { 449 | content: "\f017"; 450 | } 451 | .icon-road:before { 452 | content: "\f018"; 453 | } 454 | .icon-download-alt:before { 455 | content: "\f019"; 456 | } 457 | .icon-download:before { 458 | content: "\f01a"; 459 | } 460 | .icon-upload:before { 461 | content: "\f01b"; 462 | } 463 | .icon-inbox:before { 464 | content: "\f01c"; 465 | } 466 | .icon-play-circle:before { 467 | content: "\f01d"; 468 | } 469 | .icon-rotate-right:before, 470 | .icon-repeat:before { 471 | content: "\f01e"; 472 | } 473 | .icon-refresh:before { 474 | content: "\f021"; 475 | } 476 | .icon-list-alt:before { 477 | content: "\f022"; 478 | } 479 | .icon-lock:before { 480 | content: "\f023"; 481 | } 482 | .icon-flag:before { 483 | content: "\f024"; 484 | } 485 | .icon-headphones:before { 486 | content: "\f025"; 487 | } 488 | .icon-volume-off:before { 489 | content: "\f026"; 490 | } 491 | .icon-volume-down:before { 492 | content: "\f027"; 493 | } 494 | .icon-volume-up:before { 495 | content: "\f028"; 496 | } 497 | .icon-qrcode:before { 498 | content: "\f029"; 499 | } 500 | .icon-barcode:before { 501 | content: "\f02a"; 502 | } 503 | .icon-tag:before { 504 | content: "\f02b"; 505 | } 506 | .icon-tags:before { 507 | content: "\f02c"; 508 | } 509 | .icon-book:before { 510 | content: "\f02d"; 511 | } 512 | .icon-bookmark:before { 513 | content: "\f02e"; 514 | } 515 | .icon-print:before { 516 | content: "\f02f"; 517 | } 518 | .icon-camera:before { 519 | content: "\f030"; 520 | } 521 | .icon-font:before { 522 | content: "\f031"; 523 | } 524 | .icon-bold:before { 525 | content: "\f032"; 526 | } 527 | .icon-italic:before { 528 | content: "\f033"; 529 | } 530 | .icon-text-height:before { 531 | content: "\f034"; 532 | } 533 | .icon-text-width:before { 534 | content: "\f035"; 535 | } 536 | .icon-align-left:before { 537 | content: "\f036"; 538 | } 539 | .icon-align-center:before { 540 | content: "\f037"; 541 | } 542 | .icon-align-right:before { 543 | content: "\f038"; 544 | } 545 | .icon-align-justify:before { 546 | content: "\f039"; 547 | } 548 | .icon-list:before { 549 | content: "\f03a"; 550 | } 551 | .icon-indent-left:before { 552 | content: "\f03b"; 553 | } 554 | .icon-indent-right:before { 555 | content: "\f03c"; 556 | } 557 | .icon-facetime-video:before { 558 | content: "\f03d"; 559 | } 560 | .icon-picture:before { 561 | content: "\f03e"; 562 | } 563 | .icon-pencil:before { 564 | content: "\f040"; 565 | } 566 | .icon-map-marker:before { 567 | content: "\f041"; 568 | } 569 | .icon-adjust:before { 570 | content: "\f042"; 571 | } 572 | .icon-tint:before { 573 | content: "\f043"; 574 | } 575 | .icon-edit:before { 576 | content: "\f044"; 577 | } 578 | .icon-share:before { 579 | content: "\f045"; 580 | } 581 | .icon-check:before { 582 | content: "\f046"; 583 | } 584 | .icon-move:before { 585 | content: "\f047"; 586 | } 587 | .icon-step-backward:before { 588 | content: "\f048"; 589 | } 590 | .icon-fast-backward:before { 591 | content: "\f049"; 592 | } 593 | .icon-backward:before { 594 | content: "\f04a"; 595 | } 596 | .icon-play:before { 597 | content: "\f04b"; 598 | } 599 | .icon-pause:before { 600 | content: "\f04c"; 601 | } 602 | .icon-stop:before { 603 | content: "\f04d"; 604 | } 605 | .icon-forward:before { 606 | content: "\f04e"; 607 | } 608 | .icon-fast-forward:before { 609 | content: "\f050"; 610 | } 611 | .icon-step-forward:before { 612 | content: "\f051"; 613 | } 614 | .icon-eject:before { 615 | content: "\f052"; 616 | } 617 | .icon-chevron-left:before { 618 | content: "\f053"; 619 | } 620 | .icon-chevron-right:before { 621 | content: "\f054"; 622 | } 623 | .icon-plus-sign:before { 624 | content: "\f055"; 625 | } 626 | .icon-minus-sign:before { 627 | content: "\f056"; 628 | } 629 | .icon-remove-sign:before { 630 | content: "\f057"; 631 | } 632 | .icon-ok-sign:before { 633 | content: "\f058"; 634 | } 635 | .icon-question-sign:before { 636 | content: "\f059"; 637 | } 638 | .icon-info-sign:before { 639 | content: "\f05a"; 640 | } 641 | .icon-screenshot:before { 642 | content: "\f05b"; 643 | } 644 | .icon-remove-circle:before { 645 | content: "\f05c"; 646 | } 647 | .icon-ok-circle:before { 648 | content: "\f05d"; 649 | } 650 | .icon-ban-circle:before { 651 | content: "\f05e"; 652 | } 653 | .icon-arrow-left:before { 654 | content: "\f060"; 655 | } 656 | .icon-arrow-right:before { 657 | content: "\f061"; 658 | } 659 | .icon-arrow-up:before { 660 | content: "\f062"; 661 | } 662 | .icon-arrow-down:before { 663 | content: "\f063"; 664 | } 665 | .icon-mail-forward:before, 666 | .icon-share-alt:before { 667 | content: "\f064"; 668 | } 669 | .icon-resize-full:before { 670 | content: "\f065"; 671 | } 672 | .icon-resize-small:before { 673 | content: "\f066"; 674 | } 675 | .icon-plus:before { 676 | content: "\f067"; 677 | } 678 | .icon-minus:before { 679 | content: "\f068"; 680 | } 681 | .icon-asterisk:before { 682 | content: "\f069"; 683 | } 684 | .icon-exclamation-sign:before { 685 | content: "\f06a"; 686 | } 687 | .icon-gift:before { 688 | content: "\f06b"; 689 | } 690 | .icon-leaf:before { 691 | content: "\f06c"; 692 | } 693 | .icon-fire:before { 694 | content: "\f06d"; 695 | } 696 | .icon-eye-open:before { 697 | content: "\f06e"; 698 | } 699 | .icon-eye-close:before { 700 | content: "\f070"; 701 | } 702 | .icon-warning-sign:before { 703 | content: "\f071"; 704 | } 705 | .icon-plane:before { 706 | content: "\f072"; 707 | } 708 | .icon-calendar:before { 709 | content: "\f073"; 710 | } 711 | .icon-random:before { 712 | content: "\f074"; 713 | } 714 | .icon-comment:before { 715 | content: "\f075"; 716 | } 717 | .icon-magnet:before { 718 | content: "\f076"; 719 | } 720 | .icon-chevron-up:before { 721 | content: "\f077"; 722 | } 723 | .icon-chevron-down:before { 724 | content: "\f078"; 725 | } 726 | .icon-retweet:before { 727 | content: "\f079"; 728 | } 729 | .icon-shopping-cart:before { 730 | content: "\f07a"; 731 | } 732 | .icon-folder-close:before { 733 | content: "\f07b"; 734 | } 735 | .icon-folder-open:before { 736 | content: "\f07c"; 737 | } 738 | .icon-resize-vertical:before { 739 | content: "\f07d"; 740 | } 741 | .icon-resize-horizontal:before { 742 | content: "\f07e"; 743 | } 744 | .icon-bar-chart:before { 745 | content: "\f080"; 746 | } 747 | .icon-twitter-sign:before { 748 | content: "\f081"; 749 | } 750 | .icon-facebook-sign:before { 751 | content: "\f082"; 752 | } 753 | .icon-camera-retro:before { 754 | content: "\f083"; 755 | } 756 | .icon-key:before { 757 | content: "\f084"; 758 | } 759 | .icon-gears:before, 760 | .icon-cogs:before { 761 | content: "\f085"; 762 | } 763 | .icon-comments:before { 764 | content: "\f086"; 765 | } 766 | .icon-thumbs-up-alt:before { 767 | content: "\f087"; 768 | } 769 | .icon-thumbs-down-alt:before { 770 | content: "\f088"; 771 | } 772 | .icon-star-half:before { 773 | content: "\f089"; 774 | } 775 | .icon-heart-empty:before { 776 | content: "\f08a"; 777 | } 778 | .icon-signout:before { 779 | content: "\f08b"; 780 | } 781 | .icon-linkedin-sign:before { 782 | content: "\f08c"; 783 | } 784 | .icon-pushpin:before { 785 | content: "\f08d"; 786 | } 787 | .icon-external-link:before { 788 | content: "\f08e"; 789 | } 790 | .icon-signin:before { 791 | content: "\f090"; 792 | } 793 | .icon-trophy:before { 794 | content: "\f091"; 795 | } 796 | .icon-github-sign:before { 797 | content: "\f092"; 798 | } 799 | .icon-upload-alt:before { 800 | content: "\f093"; 801 | } 802 | .icon-lemon:before { 803 | content: "\f094"; 804 | } 805 | .icon-phone:before { 806 | content: "\f095"; 807 | } 808 | .icon-unchecked:before, 809 | .icon-check-empty:before { 810 | content: "\f096"; 811 | } 812 | .icon-bookmark-empty:before { 813 | content: "\f097"; 814 | } 815 | .icon-phone-sign:before { 816 | content: "\f098"; 817 | } 818 | .icon-twitter:before { 819 | content: "\f099"; 820 | } 821 | .icon-facebook:before { 822 | content: "\f09a"; 823 | } 824 | .icon-github:before { 825 | content: "\f09b"; 826 | } 827 | .icon-unlock:before { 828 | content: "\f09c"; 829 | } 830 | .icon-credit-card:before { 831 | content: "\f09d"; 832 | } 833 | .icon-rss:before { 834 | content: "\f09e"; 835 | } 836 | .icon-hdd:before { 837 | content: "\f0a0"; 838 | } 839 | .icon-bullhorn:before { 840 | content: "\f0a1"; 841 | } 842 | .icon-bell:before { 843 | content: "\f0a2"; 844 | } 845 | .icon-certificate:before { 846 | content: "\f0a3"; 847 | } 848 | .icon-hand-right:before { 849 | content: "\f0a4"; 850 | } 851 | .icon-hand-left:before { 852 | content: "\f0a5"; 853 | } 854 | .icon-hand-up:before { 855 | content: "\f0a6"; 856 | } 857 | .icon-hand-down:before { 858 | content: "\f0a7"; 859 | } 860 | .icon-circle-arrow-left:before { 861 | content: "\f0a8"; 862 | } 863 | .icon-circle-arrow-right:before { 864 | content: "\f0a9"; 865 | } 866 | .icon-circle-arrow-up:before { 867 | content: "\f0aa"; 868 | } 869 | .icon-circle-arrow-down:before { 870 | content: "\f0ab"; 871 | } 872 | .icon-globe:before { 873 | content: "\f0ac"; 874 | } 875 | .icon-wrench:before { 876 | content: "\f0ad"; 877 | } 878 | .icon-tasks:before { 879 | content: "\f0ae"; 880 | } 881 | .icon-filter:before { 882 | content: "\f0b0"; 883 | } 884 | .icon-briefcase:before { 885 | content: "\f0b1"; 886 | } 887 | .icon-fullscreen:before { 888 | content: "\f0b2"; 889 | } 890 | .icon-group:before { 891 | content: "\f0c0"; 892 | } 893 | .icon-link:before { 894 | content: "\f0c1"; 895 | } 896 | .icon-cloud:before { 897 | content: "\f0c2"; 898 | } 899 | .icon-beaker:before { 900 | content: "\f0c3"; 901 | } 902 | .icon-cut:before { 903 | content: "\f0c4"; 904 | } 905 | .icon-copy:before { 906 | content: "\f0c5"; 907 | } 908 | .icon-paperclip:before, 909 | .icon-paper-clip:before { 910 | content: "\f0c6"; 911 | } 912 | .icon-save:before { 913 | content: "\f0c7"; 914 | } 915 | .icon-sign-blank:before { 916 | content: "\f0c8"; 917 | } 918 | .icon-reorder:before { 919 | content: "\f0c9"; 920 | } 921 | .icon-list-ul:before { 922 | content: "\f0ca"; 923 | } 924 | .icon-list-ol:before { 925 | content: "\f0cb"; 926 | } 927 | .icon-strikethrough:before { 928 | content: "\f0cc"; 929 | } 930 | .icon-underline:before { 931 | content: "\f0cd"; 932 | } 933 | .icon-table:before { 934 | content: "\f0ce"; 935 | } 936 | .icon-magic:before { 937 | content: "\f0d0"; 938 | } 939 | .icon-truck:before { 940 | content: "\f0d1"; 941 | } 942 | .icon-pinterest:before { 943 | content: "\f0d2"; 944 | } 945 | .icon-pinterest-sign:before { 946 | content: "\f0d3"; 947 | } 948 | .icon-google-plus-sign:before { 949 | content: "\f0d4"; 950 | } 951 | .icon-google-plus:before { 952 | content: "\f0d5"; 953 | } 954 | .icon-money:before { 955 | content: "\f0d6"; 956 | } 957 | .icon-caret-down:before { 958 | content: "\f0d7"; 959 | } 960 | .icon-caret-up:before { 961 | content: "\f0d8"; 962 | } 963 | .icon-caret-left:before { 964 | content: "\f0d9"; 965 | } 966 | .icon-caret-right:before { 967 | content: "\f0da"; 968 | } 969 | .icon-columns:before { 970 | content: "\f0db"; 971 | } 972 | .icon-sort:before { 973 | content: "\f0dc"; 974 | } 975 | .icon-sort-down:before { 976 | content: "\f0dd"; 977 | } 978 | .icon-sort-up:before { 979 | content: "\f0de"; 980 | } 981 | .icon-envelope:before { 982 | content: "\f0e0"; 983 | } 984 | .icon-linkedin:before { 985 | content: "\f0e1"; 986 | } 987 | .icon-rotate-left:before, 988 | .icon-undo:before { 989 | content: "\f0e2"; 990 | } 991 | .icon-legal:before { 992 | content: "\f0e3"; 993 | } 994 | .icon-dashboard:before { 995 | content: "\f0e4"; 996 | } 997 | .icon-comment-alt:before { 998 | content: "\f0e5"; 999 | } 1000 | .icon-comments-alt:before { 1001 | content: "\f0e6"; 1002 | } 1003 | .icon-bolt:before { 1004 | content: "\f0e7"; 1005 | } 1006 | .icon-sitemap:before { 1007 | content: "\f0e8"; 1008 | } 1009 | .icon-umbrella:before { 1010 | content: "\f0e9"; 1011 | } 1012 | .icon-paste:before { 1013 | content: "\f0ea"; 1014 | } 1015 | .icon-lightbulb:before { 1016 | content: "\f0eb"; 1017 | } 1018 | .icon-exchange:before { 1019 | content: "\f0ec"; 1020 | } 1021 | .icon-cloud-download:before { 1022 | content: "\f0ed"; 1023 | } 1024 | .icon-cloud-upload:before { 1025 | content: "\f0ee"; 1026 | } 1027 | .icon-user-md:before { 1028 | content: "\f0f0"; 1029 | } 1030 | .icon-stethoscope:before { 1031 | content: "\f0f1"; 1032 | } 1033 | .icon-suitcase:before { 1034 | content: "\f0f2"; 1035 | } 1036 | .icon-bell-alt:before { 1037 | content: "\f0f3"; 1038 | } 1039 | .icon-coffee:before { 1040 | content: "\f0f4"; 1041 | } 1042 | .icon-food:before { 1043 | content: "\f0f5"; 1044 | } 1045 | .icon-file-text-alt:before { 1046 | content: "\f0f6"; 1047 | } 1048 | .icon-building:before { 1049 | content: "\f0f7"; 1050 | } 1051 | .icon-hospital:before { 1052 | content: "\f0f8"; 1053 | } 1054 | .icon-ambulance:before { 1055 | content: "\f0f9"; 1056 | } 1057 | .icon-medkit:before { 1058 | content: "\f0fa"; 1059 | } 1060 | .icon-fighter-jet:before { 1061 | content: "\f0fb"; 1062 | } 1063 | .icon-beer:before { 1064 | content: "\f0fc"; 1065 | } 1066 | .icon-h-sign:before { 1067 | content: "\f0fd"; 1068 | } 1069 | .icon-plus-sign-alt:before { 1070 | content: "\f0fe"; 1071 | } 1072 | .icon-double-angle-left:before { 1073 | content: "\f100"; 1074 | } 1075 | .icon-double-angle-right:before { 1076 | content: "\f101"; 1077 | } 1078 | .icon-double-angle-up:before { 1079 | content: "\f102"; 1080 | } 1081 | .icon-double-angle-down:before { 1082 | content: "\f103"; 1083 | } 1084 | .icon-angle-left:before { 1085 | content: "\f104"; 1086 | } 1087 | .icon-angle-right:before { 1088 | content: "\f105"; 1089 | } 1090 | .icon-angle-up:before { 1091 | content: "\f106"; 1092 | } 1093 | .icon-angle-down:before { 1094 | content: "\f107"; 1095 | } 1096 | .icon-desktop:before { 1097 | content: "\f108"; 1098 | } 1099 | .icon-laptop:before { 1100 | content: "\f109"; 1101 | } 1102 | .icon-tablet:before { 1103 | content: "\f10a"; 1104 | } 1105 | .icon-mobile-phone:before { 1106 | content: "\f10b"; 1107 | } 1108 | .icon-circle-blank:before { 1109 | content: "\f10c"; 1110 | } 1111 | .icon-quote-left:before { 1112 | content: "\f10d"; 1113 | } 1114 | .icon-quote-right:before { 1115 | content: "\f10e"; 1116 | } 1117 | .icon-spinner:before { 1118 | content: "\f110"; 1119 | } 1120 | .icon-circle:before { 1121 | content: "\f111"; 1122 | } 1123 | .icon-mail-reply:before, 1124 | .icon-reply:before { 1125 | content: "\f112"; 1126 | } 1127 | .icon-github-alt:before { 1128 | content: "\f113"; 1129 | } 1130 | .icon-folder-close-alt:before { 1131 | content: "\f114"; 1132 | } 1133 | .icon-folder-open-alt:before { 1134 | content: "\f115"; 1135 | } 1136 | .icon-expand-alt:before { 1137 | content: "\f116"; 1138 | } 1139 | .icon-collapse-alt:before { 1140 | content: "\f117"; 1141 | } 1142 | .icon-smile:before { 1143 | content: "\f118"; 1144 | } 1145 | .icon-frown:before { 1146 | content: "\f119"; 1147 | } 1148 | .icon-meh:before { 1149 | content: "\f11a"; 1150 | } 1151 | .icon-gamepad:before { 1152 | content: "\f11b"; 1153 | } 1154 | .icon-keyboard:before { 1155 | content: "\f11c"; 1156 | } 1157 | .icon-flag-alt:before { 1158 | content: "\f11d"; 1159 | } 1160 | .icon-flag-checkered:before { 1161 | content: "\f11e"; 1162 | } 1163 | .icon-terminal:before { 1164 | content: "\f120"; 1165 | } 1166 | .icon-code:before { 1167 | content: "\f121"; 1168 | } 1169 | .icon-reply-all:before { 1170 | content: "\f122"; 1171 | } 1172 | .icon-mail-reply-all:before { 1173 | content: "\f122"; 1174 | } 1175 | .icon-star-half-full:before, 1176 | .icon-star-half-empty:before { 1177 | content: "\f123"; 1178 | } 1179 | .icon-location-arrow:before { 1180 | content: "\f124"; 1181 | } 1182 | .icon-crop:before { 1183 | content: "\f125"; 1184 | } 1185 | .icon-code-fork:before { 1186 | content: "\f126"; 1187 | } 1188 | .icon-unlink:before { 1189 | content: "\f127"; 1190 | } 1191 | .icon-question:before { 1192 | content: "\f128"; 1193 | } 1194 | .icon-info:before { 1195 | content: "\f129"; 1196 | } 1197 | .icon-exclamation:before { 1198 | content: "\f12a"; 1199 | } 1200 | .icon-superscript:before { 1201 | content: "\f12b"; 1202 | } 1203 | .icon-subscript:before { 1204 | content: "\f12c"; 1205 | } 1206 | .icon-eraser:before { 1207 | content: "\f12d"; 1208 | } 1209 | .icon-puzzle-piece:before { 1210 | content: "\f12e"; 1211 | } 1212 | .icon-microphone:before { 1213 | content: "\f130"; 1214 | } 1215 | .icon-microphone-off:before { 1216 | content: "\f131"; 1217 | } 1218 | .icon-shield:before { 1219 | content: "\f132"; 1220 | } 1221 | .icon-calendar-empty:before { 1222 | content: "\f133"; 1223 | } 1224 | .icon-fire-extinguisher:before { 1225 | content: "\f134"; 1226 | } 1227 | .icon-rocket:before { 1228 | content: "\f135"; 1229 | } 1230 | .icon-maxcdn:before { 1231 | content: "\f136"; 1232 | } 1233 | .icon-chevron-sign-left:before { 1234 | content: "\f137"; 1235 | } 1236 | .icon-chevron-sign-right:before { 1237 | content: "\f138"; 1238 | } 1239 | .icon-chevron-sign-up:before { 1240 | content: "\f139"; 1241 | } 1242 | .icon-chevron-sign-down:before { 1243 | content: "\f13a"; 1244 | } 1245 | .icon-html5:before { 1246 | content: "\f13b"; 1247 | } 1248 | .icon-css3:before { 1249 | content: "\f13c"; 1250 | } 1251 | .icon-anchor:before { 1252 | content: "\f13d"; 1253 | } 1254 | .icon-unlock-alt:before { 1255 | content: "\f13e"; 1256 | } 1257 | .icon-bullseye:before { 1258 | content: "\f140"; 1259 | } 1260 | .icon-ellipsis-horizontal:before { 1261 | content: "\f141"; 1262 | } 1263 | .icon-ellipsis-vertical:before { 1264 | content: "\f142"; 1265 | } 1266 | .icon-rss-sign:before { 1267 | content: "\f143"; 1268 | } 1269 | .icon-play-sign:before { 1270 | content: "\f144"; 1271 | } 1272 | .icon-ticket:before { 1273 | content: "\f145"; 1274 | } 1275 | .icon-minus-sign-alt:before { 1276 | content: "\f146"; 1277 | } 1278 | .icon-check-minus:before { 1279 | content: "\f147"; 1280 | } 1281 | .icon-level-up:before { 1282 | content: "\f148"; 1283 | } 1284 | .icon-level-down:before { 1285 | content: "\f149"; 1286 | } 1287 | .icon-check-sign:before { 1288 | content: "\f14a"; 1289 | } 1290 | .icon-edit-sign:before { 1291 | content: "\f14b"; 1292 | } 1293 | .icon-external-link-sign:before { 1294 | content: "\f14c"; 1295 | } 1296 | .icon-share-sign:before { 1297 | content: "\f14d"; 1298 | } 1299 | .icon-compass:before { 1300 | content: "\f14e"; 1301 | } 1302 | .icon-collapse:before { 1303 | content: "\f150"; 1304 | } 1305 | .icon-collapse-top:before { 1306 | content: "\f151"; 1307 | } 1308 | .icon-expand:before { 1309 | content: "\f152"; 1310 | } 1311 | .icon-euro:before, 1312 | .icon-eur:before { 1313 | content: "\f153"; 1314 | } 1315 | .icon-gbp:before { 1316 | content: "\f154"; 1317 | } 1318 | .icon-dollar:before, 1319 | .icon-usd:before { 1320 | content: "\f155"; 1321 | } 1322 | .icon-rupee:before, 1323 | .icon-inr:before { 1324 | content: "\f156"; 1325 | } 1326 | .icon-yen:before, 1327 | .icon-jpy:before { 1328 | content: "\f157"; 1329 | } 1330 | .icon-renminbi:before, 1331 | .icon-cny:before { 1332 | content: "\f158"; 1333 | } 1334 | .icon-won:before, 1335 | .icon-krw:before { 1336 | content: "\f159"; 1337 | } 1338 | .icon-bitcoin:before, 1339 | .icon-btc:before { 1340 | content: "\f15a"; 1341 | } 1342 | .icon-file:before { 1343 | content: "\f15b"; 1344 | } 1345 | .icon-file-text:before { 1346 | content: "\f15c"; 1347 | } 1348 | .icon-sort-by-alphabet:before { 1349 | content: "\f15d"; 1350 | } 1351 | .icon-sort-by-alphabet-alt:before { 1352 | content: "\f15e"; 1353 | } 1354 | .icon-sort-by-attributes:before { 1355 | content: "\f160"; 1356 | } 1357 | .icon-sort-by-attributes-alt:before { 1358 | content: "\f161"; 1359 | } 1360 | .icon-sort-by-order:before { 1361 | content: "\f162"; 1362 | } 1363 | .icon-sort-by-order-alt:before { 1364 | content: "\f163"; 1365 | } 1366 | .icon-thumbs-up:before { 1367 | content: "\f164"; 1368 | } 1369 | .icon-thumbs-down:before { 1370 | content: "\f165"; 1371 | } 1372 | .icon-youtube-sign:before { 1373 | content: "\f166"; 1374 | } 1375 | .icon-youtube:before { 1376 | content: "\f167"; 1377 | } 1378 | .icon-xing:before { 1379 | content: "\f168"; 1380 | } 1381 | .icon-xing-sign:before { 1382 | content: "\f169"; 1383 | } 1384 | .icon-youtube-play:before { 1385 | content: "\f16a"; 1386 | } 1387 | .icon-dropbox:before { 1388 | content: "\f16b"; 1389 | } 1390 | .icon-stackexchange:before { 1391 | content: "\f16c"; 1392 | } 1393 | .icon-instagram:before { 1394 | content: "\f16d"; 1395 | } 1396 | .icon-flickr:before { 1397 | content: "\f16e"; 1398 | } 1399 | .icon-adn:before { 1400 | content: "\f170"; 1401 | } 1402 | .icon-bitbucket:before { 1403 | content: "\f171"; 1404 | } 1405 | .icon-bitbucket-sign:before { 1406 | content: "\f172"; 1407 | } 1408 | .icon-tumblr:before { 1409 | content: "\f173"; 1410 | } 1411 | .icon-tumblr-sign:before { 1412 | content: "\f174"; 1413 | } 1414 | .icon-long-arrow-down:before { 1415 | content: "\f175"; 1416 | } 1417 | .icon-long-arrow-up:before { 1418 | content: "\f176"; 1419 | } 1420 | .icon-long-arrow-left:before { 1421 | content: "\f177"; 1422 | } 1423 | .icon-long-arrow-right:before { 1424 | content: "\f178"; 1425 | } 1426 | .icon-apple:before { 1427 | content: "\f179"; 1428 | } 1429 | .icon-windows:before { 1430 | content: "\f17a"; 1431 | } 1432 | .icon-android:before { 1433 | content: "\f17b"; 1434 | } 1435 | .icon-linux:before { 1436 | content: "\f17c"; 1437 | } 1438 | .icon-dribbble:before { 1439 | content: "\f17d"; 1440 | } 1441 | .icon-skype:before { 1442 | content: "\f17e"; 1443 | } 1444 | .icon-foursquare:before { 1445 | content: "\f180"; 1446 | } 1447 | .icon-trello:before { 1448 | content: "\f181"; 1449 | } 1450 | .icon-female:before { 1451 | content: "\f182"; 1452 | } 1453 | .icon-male:before { 1454 | content: "\f183"; 1455 | } 1456 | .icon-gittip:before { 1457 | content: "\f184"; 1458 | } 1459 | .icon-sun:before { 1460 | content: "\f185"; 1461 | } 1462 | .icon-moon:before { 1463 | content: "\f186"; 1464 | } 1465 | .icon-archive:before { 1466 | content: "\f187"; 1467 | } 1468 | .icon-bug:before { 1469 | content: "\f188"; 1470 | } 1471 | .icon-vk:before { 1472 | content: "\f189"; 1473 | } 1474 | .icon-weibo:before { 1475 | content: "\f18a"; 1476 | } 1477 | .icon-renren:before { 1478 | content: "\f18b"; 1479 | } 1480 | --------------------------------------------------------------------------------