├── .gitignore
├── screenshot.jpg
├── Gemfile
├── lib
├── redis_dashboard
│ ├── views
│ │ ├── application.scss
│ │ ├── key
│ │ │ ├── string.erb
│ │ │ ├── unsupported.erb
│ │ │ ├── set.erb
│ │ │ ├── list.erb
│ │ │ ├── hash.erb
│ │ │ ├── zset.erb
│ │ │ └── metadata.erb
│ │ ├── key.erb
│ │ ├── info.erb
│ │ ├── config.erb
│ │ ├── keyspace.erb
│ │ ├── memory.erb
│ │ ├── stats.erb
│ │ ├── clients.erb
│ │ ├── keys.erb
│ │ ├── index.erb
│ │ ├── slowlog.erb
│ │ └── layout.erb
│ ├── command.rb
│ ├── client.rb
│ ├── public
│ │ ├── style.css
│ │ └── ariato.css
│ └── application.rb
└── redis_dashboard.rb
├── start.rb
├── CHANGELOG.md
├── Gemfile.lock
├── redis_dashboard.gemspec
├── LICENSE.txt
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .sass-cache
2 | **/.DS_Store
3 | *.gem
4 |
--------------------------------------------------------------------------------
/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BaseSecrete/redis_dashboard/HEAD/screenshot.jpg
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gem "sinatra"
4 | gem "erubi"
5 | gem "redis"
6 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/application.scss:
--------------------------------------------------------------------------------
1 | @import "stylesheets/ariato";
2 | @import "stylesheets/style";
3 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/key/string.erb:
--------------------------------------------------------------------------------
1 |
Data
2 |
3 | <%= client.connection.get(key) %>
--------------------------------------------------------------------------------
/lib/redis_dashboard/command.rb:
--------------------------------------------------------------------------------
1 | class RedisDashboard::Command
2 | attr_accessor :id, :timestamp, :microseconds, :command
3 | end
4 |
--------------------------------------------------------------------------------
/start.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH << File.expand_path(File.dirname(__FILE__)) + "/lib"
2 |
3 | require "redis_dashboard"
4 | RedisDashboard::Application.run!
5 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/key/unsupported.erb:
--------------------------------------------------------------------------------
1 | Data
2 | Sorry, but <%= client.connection.type(params[:key]) %> type is not supported.
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/key.erb:
--------------------------------------------------------------------------------
1 |
2 | <%== erb(:"key/metadata", locals: {key: params[:key]}) %>
3 |
4 | <%== render_key_data(params[:key]) %>
5 |
6 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/key/set.erb:
--------------------------------------------------------------------------------
1 | Data
2 |
3 |
4 |
5 | <% for value in client.connection.smembers(key) %>
6 |
7 | <%= value %>
8 |
9 | <% end %>
10 |
11 |
12 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/key/list.erb:
--------------------------------------------------------------------------------
1 | Data
2 |
3 |
4 |
5 | <% for value in client.connection.lrange(key, 0, -1) %>
6 |
7 | <%= value %>
8 |
9 | <% end %>
10 |
11 |
12 |
--------------------------------------------------------------------------------
/lib/redis_dashboard.rb:
--------------------------------------------------------------------------------
1 | module RedisDashboard
2 | def self.urls=(array)
3 | @urls = array
4 | end
5 |
6 | def self.urls
7 | @urls ||= [ENV["REDIS_URL"] ||"redis://localhost"]
8 | end
9 | end
10 |
11 | require "redis_dashboard/client"
12 | require "redis_dashboard/command"
13 | require "redis_dashboard/application"
14 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/key/hash.erb:
--------------------------------------------------------------------------------
1 | Data
2 |
3 |
4 |
5 | <% for (name, value) in client.connection.hgetall(key) %>
6 |
7 | <%= name %>
8 | <%= value %>
9 |
10 | <% end %>
11 |
12 |
13 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/key/zset.erb:
--------------------------------------------------------------------------------
1 | Data
2 |
3 |
4 |
5 |
6 | Score
7 | Value
8 |
9 | <% client.connection.call([:zrange, key, 0, -1, "WITHSCORES"]).each_slice(2) do |value, score| %>
10 |
11 | <%= score %>
12 | <%= value %>
13 |
14 | <% end %>
15 |
16 |
17 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/info.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <% for (key,value) in info %>
4 |
5 | <%= key %>
6 | <%= value %>
7 |
8 | <% end %>
9 |
10 |
11 |
16 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/config.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <% for (key, value) in config %>
4 |
5 | <%= key %>
6 | <%= value %>
7 |
8 | <% end %>
9 |
10 |
11 |
16 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog of Redis Dashboard
2 |
3 | ## 0.3.1 (2023-09-12)
4 |
5 | * Fix key filtering when there is no query
6 |
7 | ## 0.3.0 (2021-11-04)
8 |
9 | * Add keyspace explorer
10 | * Use friendly URLs with server host name
11 | * Mute Redis error when server is too old to support memory command
12 | * Use Ariato CSS framework https://ariato.org
13 | * Remove sassc dependency to switch to plain CSS
14 |
15 | ## 0.2.0 (2020-04-23)
16 |
17 | * Add memory stats
18 | * Replace deprecated sass by sassc
19 |
20 | ## 0.1.6 (2019-08-16)
21 |
22 | * Switch to erubi
23 | * Escape HTML by default
24 | * Fix cache hit ratio
25 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | connection_pool (2.3.0)
5 | erubi (1.12.0)
6 | mustermann (3.0.0)
7 | ruby2_keywords (~> 0.0.1)
8 | rack (2.2.6.2)
9 | rack-protection (3.0.5)
10 | rack
11 | redis (5.0.6)
12 | redis-client (>= 0.9.0)
13 | redis-client (0.12.1)
14 | connection_pool
15 | ruby2_keywords (0.0.5)
16 | sinatra (3.0.5)
17 | mustermann (~> 3.0)
18 | rack (~> 2.2, >= 2.2.4)
19 | rack-protection (= 3.0.5)
20 | tilt (~> 2.0)
21 | tilt (2.0.11)
22 |
23 | PLATFORMS
24 | ruby
25 |
26 | DEPENDENCIES
27 | erubi
28 | redis
29 | sinatra
30 |
31 | BUNDLED WITH
32 | 2.2.22
33 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/keyspace.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | db
6 | keys
7 | expires
8 | avg_ttl
9 |
10 |
11 |
12 | <% for (db, stats) in keyspace %>
13 |
14 |
15 | "><%= db %>
16 |
17 | <%= stats["keys"] %>
18 | <%= stats["expires"] %>
19 | <%= stats["avg_ttl"] %>
20 |
21 | <% end %>
22 |
23 |
24 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/memory.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <% for (key,value) in stats %>
5 |
6 | <%= key %>
7 |
8 | <% if value.is_a?(Hash) %>
9 | <% for (sub_key, sub_value) in value %>
10 | <%= "#{sub_key}: #{sub_value}" %>
11 | <% end %>
12 | <% else %>
13 | <%= value %>
14 | <% end %>
15 |
16 |
17 | <% end %>
18 |
19 |
20 |
21 |
26 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/stats.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Command
6 | Calls
7 | AVG time
8 | Total time
9 | Impact
10 |
11 |
12 |
13 | <% for (key,hash) in stats %>
14 |
15 | <%= key %>
16 | <%= hash["calls"] %>
17 | <%== format_usec hash["usec_per_call"] %>
18 | <%== format_usec hash["usec"] %>
19 | <%== format_impact_percentage hash["impact"] %>
20 |
21 | <% end %>
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/clients.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <% for key in (keys = clients.first.keys) %>
5 | <%= key %>
6 | <% end %>
7 |
8 |
9 | <% for client in clients %>
10 |
11 | <% for key in keys %>
12 | <% if key == "events" %>
13 | <%= client[key] %>
14 | <% else %>
15 | <%= client[key] %>
16 | <% end %>
17 | <% end %>
18 |
19 | <% end %>
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/keys.erb:
--------------------------------------------------------------------------------
1 |
2 |
5 | <% if !params[:query].to_s.empty? %>
6 |
7 | <% if keys.size == 1 %>
8 | <%= keys.size %> key found
9 | <% else %>
10 | <%= keys.size %> keys found
11 | <% end %>
12 | <% if keys.size > 1000 %>
13 | (only 1000 are listed)
14 | <% end %>
15 |
16 |
17 |
18 | <% for key in keys[0..1000] %>
19 |
20 | "><%= key %>
21 |
22 | <% end %>
23 |
24 |
25 | <% end %>
26 |
27 |
--------------------------------------------------------------------------------
/redis_dashboard.gemspec:
--------------------------------------------------------------------------------
1 | lib = File.expand_path('../lib', __FILE__)
2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3 |
4 | Gem::Specification.new do |spec|
5 | spec.name = "redis_dashboard"
6 | spec.version = "0.3.1"
7 | spec.authors = ["Alexis Bernard"]
8 | spec.email = ["alexis@bernard.io"]
9 | spec.summary = "Sinatra app to monitor Redis servers."
10 | spec.description = "Sinatra app to monitor Redis servers"
11 | spec.homepage = "https://github.com/BaseSecrete/redis_dashboard"
12 | spec.license = "MIT"
13 |
14 | spec.files = `git ls-files -z`.split("\x0")
15 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
16 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
17 | spec.require_paths = ["lib"]
18 |
19 | spec.add_runtime_dependency "sinatra"
20 | spec.add_runtime_dependency "erubi"
21 | spec.add_runtime_dependency "redis"
22 | end
23 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/index.erb:
--------------------------------------------------------------------------------
1 |
2 | <% clients.each_with_index do |client, index| %>
3 | <% info = client.info %>
4 |
5 |
8 |
9 |
10 | Connections
11 | <%= info["connected_clients"] %>
12 |
13 |
14 | Memory
15 | <%= info["used_memory_human"] %>
16 |
17 |
18 | Commands per second
19 | <%= info["instantaneous_ops_per_sec"] %>
20 |
21 |
22 | Cache hit ratio
23 | <%== format_impact_percentage compute_cache_hit_ratio(info) %>
24 |
25 |
26 |
27 | <% end %>
28 |
29 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Base Secrète
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/slowlog.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | List up to <%= client.config["slowlog-max-len"] %> commands slower than <%== format_usec client.config["slowlog-log-slower-than"] %>.
4 | It is defined in your config by slowlog-max-len and slowlog-log-slower-than .
5 |
6 |
7 |
8 |
9 | Command
10 | ID
11 | At
12 | Duration
13 |
14 |
15 |
16 | <% for cmd in commands %>
17 |
18 | <%= cmd.command.join(" ") %>
19 | <%= cmd.id %>
20 | <%= epoch_to_short_date_time cmd.timestamp %>
21 | <%== format_usec cmd.microseconds %>
22 |
23 | <% end %>
24 |
25 |
26 |
27 |
32 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/key/metadata.erb:
--------------------------------------------------------------------------------
1 | Metadata
2 |
3 |
4 |
5 |
6 | Key
7 | <%= key %>
8 |
9 |
10 |
11 | Type
12 | <%= key_type = client.connection.type(key) %>
13 |
14 |
15 |
16 | Bytes
17 | <%= mute_redis_command_error { client.connection.memory(:usage, key) } %>
18 |
19 |
20 |
21 | TTL
22 | <%= client.connection.ttl(key) %>
23 |
24 |
25 |
26 | Object refcount
27 | <%= client.connection.object("REFCOUNT", key) %>
28 |
29 |
30 |
31 | Object encoding
32 | <%= client.connection.object("ENCODING", key) %>
33 |
34 |
35 |
36 | Object idletime
37 | <%= client.connection.object("IDLETIME", key) %>
38 |
39 |
40 | <% if key_type == "list" %>
41 |
42 | Length
43 | <%= client.connection.llen(key) %>
44 |
45 | <% end %>
46 |
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Redis Dashboard
2 |
3 | A Sinatra web app showing monitoring informations about your Redis servers.
4 | You can run it in standalone or inside your Rails app.
5 |
6 | 
7 |
8 | ## Features
9 |
10 | #### List of your redis servers
11 | - Connections
12 | - Memory
13 | - Commands per second
14 |
15 | #### Detailed views for each server
16 | - Redis INFO output
17 | - Redis CONFIG GET output
18 | - Redis CLIENT LIST output
19 | - Redis SLOWLOG GET output
20 |
21 | ## Installation inside a Rails app
22 |
23 | Add to your Gemfile `gem "redis_dashboard"` and run `bundle install`.
24 |
25 | Then mount the app from `config/routes.rb`:
26 | ```ruby
27 | mount RedisDashboard::Application, at: "redis"
28 | ```
29 |
30 | By default Redis dashboard tries to connect to `REDIS_URL` environment variable or to `localhost`. You can specify any other URL by adding an initializer in `config/initializers/redis_dashboard.rb` :
31 | ```ruby
32 | RedisDashboard.urls = [ENV["REDIS_URL"] || "redis://localhost"]
33 | ```
34 |
35 | Finally visit http://localhost:3000/redis.
36 |
37 | ## Authentication and permissions
38 |
39 | To protect your dashboard you can setup a basic HTTP authentication :
40 |
41 | ```ruby
42 | # config/initializers/redis_dashboard.rb
43 | RedisDashboard::Application.use(Rack::Auth::Basic) do |user, password|
44 | user == "USER" && password == "PASSWORD"
45 | end
46 | ```
47 |
48 | In case you handle authentication with Devise, you can perform the permission verification directly from the routes :
49 |
50 | ```ruby
51 | # config/routes.rb
52 | authenticate :user, -> (u) { u.admin? } do # Supposing there is a User#admin? method
53 | mount RedisDashboard::Application, at: "redis"
54 | end
55 | ```
56 |
57 | ## MIT License
58 |
59 | Made by [Base Secrète](https://basesecrete.com).
60 |
61 | Rails developer? Check out [RoRvsWild](https://rorvswild.com), our Ruby on Rails application monitoring tool.
62 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/client.rb:
--------------------------------------------------------------------------------
1 | class RedisDashboard::Client
2 | attr_reader :url, :connection
3 |
4 | def initialize(url)
5 | @url = url
6 | @connection ||= Redis.new(url: url)
7 | end
8 |
9 | def clients
10 | connection.client("list")
11 | end
12 |
13 | def config
14 | array_reply_to_hash(connection.config("get", "*"))
15 | end
16 |
17 | def info
18 | connection.info
19 | end
20 |
21 | def stats
22 | stats = connection.info("commandstats").sort { |a, b| b.last["usec"].to_i <=> a.last["usec"].to_i }
23 | total = stats.reduce(0) { |total, stat| total += stat.last["usec"].to_i }
24 | stats.each { |stat| stat.last["impact"] = stat.last["usec"].to_f * 100 / total }
25 | stats
26 | end
27 |
28 | def slow_commands
29 | connection.slowlog("get", config["slowlog-max-len"]).map do |entry|
30 | cmd = RedisDashboard::Command.new
31 | cmd.id = entry[0]
32 | cmd.timestamp = entry[1]
33 | cmd.microseconds = entry[2]
34 | cmd.command = entry[3]
35 | cmd
36 | end.sort{ |left, right| right.microseconds <=> left.microseconds }
37 | end
38 |
39 | def memory_stats
40 | array_reply_to_hash(connection.memory("stats"))
41 | end
42 |
43 | def keys(pattern)
44 | connection.keys(pattern)
45 | end
46 |
47 | def close
48 | connection.close if connection
49 | end
50 |
51 | def host
52 | URI(url).host
53 | end
54 |
55 | def keyspace
56 | connection.info("KEYSPACE").inject({}) do |hash, space|
57 | db, str = space
58 | hash[db] = str.split(",").inject({}) do |h, s|
59 | k, v = s.split("=")
60 | h[k] = v
61 | h
62 | end
63 | hash
64 | end
65 | end
66 |
67 | private
68 |
69 | # Array reply is a Redis format which is translated into a hash for convenience.
70 | def array_reply_to_hash(array)
71 | hash = {}
72 | while (pair = array.slice!(0, 2)).any?
73 | hash[pair.first] = pair.last.is_a?(Array) ? array_reply_to_hash(pair.last) : pair.last
74 | end
75 | hash
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/views/layout.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | <% if params[:server] %>
6 | <%= page_title %> |
7 | <% end %>
8 | Redis Dashboard
9 | ">
10 | ">
11 |
12 |
13 |
14 |
15 |
18 | <% if params[:server] %>
19 | <%= page_title %>
20 | <% end %>
21 | <% if params[:server] %>
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | <% end %>
32 |
33 |
34 | <%== yield %>
35 |
36 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/public/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | max-width: 1280px;
3 | margin: 0px auto;
4 | }
5 |
6 | body > header {
7 | display: flex;
8 | flex-wrap: wrap;
9 | }
10 |
11 | .logo {
12 | display: flex;
13 | margin: 0 0 24px 0;
14 | padding: 0;
15 | box-shadow: var(--box-shadow-m);
16 | line-height: 48px;
17 | }
18 |
19 | .logo a {
20 | padding: 0 24px;
21 | background: rgba(var(--color-red-500), 1);
22 | color: rgba(var(--color-grey-00), 1);
23 | font-size: var(--font-size-40);
24 | font-weight: 500;
25 | text-decoration: none;
26 | text-transform: uppercase;
27 | letter-spacing: 0.05em;
28 | box-shadow: var(--box-shadow-m);
29 | }
30 |
31 | body > header .server {
32 | font-weight: 400;
33 | display: block;
34 | font-size: 1rem;
35 | line-height: 48px;
36 | padding: 0 24px;
37 | margin: 0 0 24px 0;
38 | background: rgba(var(--color-grey-00), 1);
39 | box-shadow: var(--box-shadow-m);
40 | }
41 |
42 | .logo a:hover {
43 | background: rgba(var(--color-red-600), 1);
44 | color: rgba(var(--color-grey-00), 1);
45 | }
46 |
47 | body > header nav {
48 | display: flex;
49 | flex-wrap: wrap;
50 | line-height: 48px;
51 | margin: 0 0 24px auto;
52 | background-color: rgba(var(--color-grey-00), 1);
53 | box-shadow: var(--box-shadow-m);
54 | }
55 |
56 | body > header nav a:any-link {
57 | padding: 0 24px;
58 | font-weight: 500;
59 | font-size: var(--font-size-40);
60 | letter-spacing: 0.05em;;
61 | text-decoration: none;
62 | text-transform: uppercase;
63 | }
64 |
65 | body > header nav a:any-link.active,
66 | body > header nav a:any-link.active:hover,
67 | body > header nav a:any-link.active:focus {
68 | background: rgba(var(--color-red-500), 1);
69 | color: rgba(var(--color-grey-00), 1);
70 | cursor: initial;
71 | box-shadow: var(--box-shadow-m);
72 | }
73 |
74 | /* main */
75 |
76 | main * + * {
77 | margin-top: 24px;
78 | }
79 |
80 | /* table */
81 |
82 | table,
83 | th,
84 | tr {
85 | width: 100%;
86 | }
87 |
88 | tbody tr:nth-child(even) {
89 | background: inherit;
90 | }
91 |
92 | th {
93 | white-space: nowrap;
94 | }
95 |
96 | td {
97 | color: rgba(var(--color-grey-700), 1);
98 | font-family: var(--font-mono);
99 | }
100 |
101 | td small {
102 | font-size: var(--font-size-40);
103 | color: rgba(var(--color-grey-400), 1);
104 | }
105 |
106 | th,
107 | td.key {
108 | text-transform: uppercase;
109 | font-family: var(--font-sans);
110 | font-weight: 400;
111 | font-size: var(--font-size-40);
112 | letter-spacing: 0.05em;
113 | color: rgba(var(--color-grey-500), 1);
114 | }
115 |
116 | td.key {
117 | width: 30%;
118 | word-break: break-all;
119 | white-space: normal;
120 | }
121 |
122 | td.key.no-transform {
123 | text-transform: none;
124 | }
125 |
126 | td.date {
127 | white-space: nowrap;
128 | }
129 |
130 | td.wrap {
131 | word-break: break-all;
132 | white-space: normal;
133 | }
134 |
135 | /* card */
136 |
137 | .card {
138 | overflow-x: auto;
139 | border-radius: 0;
140 | padding: var(--space-3x);
141 | }
142 |
143 | .card p {
144 | max-width: none;
145 | }
146 |
147 | .card > * + * {
148 | margin-top: 24px;
149 | }
150 |
151 | .card table {
152 | margin: -24px;
153 | min-width: calc(100% + 48px);
154 | outline-style: none;
155 | }
156 |
157 | .card * + table {
158 | margin: 0 -24px -24px;
159 | }
160 |
161 | table li {
162 | margin-top: 0;
163 | list-style-type: none;
164 | }
165 |
166 | .card div .label {
167 | color: rgba(var(--color-grey-500), 1);
168 | text-transform: uppercase;
169 | font-size: var(--font-size-30);
170 | letter-spacing: 0.05em;
171 | font-weight: 500;
172 | display: block;
173 | }
174 |
175 | /* footer */
176 |
177 | body > footer {
178 | margin-top: 24px;
179 | font-size: var(--font-size-40);
180 | padding: 0 24px;
181 | }
182 |
183 | body > footer p {
184 | max-width: none;
185 | }
--------------------------------------------------------------------------------
/lib/redis_dashboard/application.rb:
--------------------------------------------------------------------------------
1 | require "sinatra/base"
2 | require "erubi"
3 | require "redis"
4 | require "uri"
5 |
6 | class RedisDashboard::Application < Sinatra::Base
7 | set :erb, escape_html: true
8 |
9 | after { close_clients }
10 |
11 | get "/" do
12 | erb(:index, locals: {clients: clients})
13 | end
14 |
15 | get "/:server/config" do
16 | erb(:config, locals: {config: client.config})
17 | end
18 |
19 | get "/:server/clients" do
20 | erb(:clients, locals: {clients: client.clients})
21 | end
22 |
23 | get "/:server/stats" do
24 | erb(:stats, locals: {stats: client.stats})
25 | end
26 |
27 | get "/:server/slowlog" do
28 | erb(:slowlog, locals: {client: client, commands: client.slow_commands})
29 | end
30 |
31 | get "/:server/memory" do
32 | stats = mute_redis_command_error { client.memory_stats } || {}
33 | erb(:memory, locals: {client: client, stats: stats })
34 | end
35 |
36 | get "/:server/keyspace" do
37 | erb(:keyspace, locals: {keyspace: client.keyspace})
38 | end
39 |
40 | get "/:server/keyspace/:db" do
41 | client.connection.select(params[:db].sub(/^db/, ""))
42 | erb(:keys, locals: {client: client, keys: client.keys(params[:query] || "")})
43 | end
44 |
45 | get "/:server/keyspace/:db/*" do
46 | params[:key] = params[:splat].first
47 | client.connection.select(params[:db].sub(/^db/, ""))
48 | erb(:key, locals: {client: client})
49 | end
50 |
51 | get "/:server" do
52 | erb(:info, locals: {info: client.info})
53 | end
54 |
55 | def client
56 | return @client if @client
57 | if url = RedisDashboard.urls.find { |url| URI(url).host == params[:server] }
58 | @client ||= RedisDashboard::Client.new(url)
59 | else
60 | raise Sinatra::NotFound
61 | end
62 | end
63 |
64 | def clients
65 | @clients ||= RedisDashboard.urls.map do |url|
66 | RedisDashboard::Client.new(url)
67 | end
68 | end
69 |
70 | def close_clients
71 | @client.close if @client
72 | @clients.each { |client| client.close } if @clients
73 | end
74 |
75 | helpers do
76 | def page_title
77 | "#{URI(client.url).host} (#{client.info["role"]})"
78 | end
79 |
80 | def epoch_to_short_date_time(epoch)
81 | Time.at(epoch).strftime("%b %d %H:%M")
82 | end
83 |
84 | def active_page_css(path)
85 | request.path_info == path && "active"
86 | end
87 |
88 | def active_path_css(path)
89 | request.path_info.start_with?(path) && "active"
90 | end
91 |
92 | def format_impact_percentage(percentage)
93 | percentage < 1 ? "< 1 % " : "#{percentage.round} % "
94 | end
95 |
96 | def format_usec(usec)
97 | "#{usec} ㎲ "
98 | end
99 |
100 | def compute_cache_hit_ratio(info)
101 | hits = info["keyspace_hits"].to_i
102 | misses = info["keyspace_misses"].to_i
103 | if (total = hits + misses) > 0
104 | hits * 100.0 / total
105 | else
106 | 0
107 | end
108 | end
109 |
110 | def render_key_data(key)
111 | type = client.connection.type(params[:key])
112 | erb(:"key/#{type}", locals: {key: key})
113 | rescue Errno::ENOENT
114 | erb(:"key/unsupported", locals: {key: key})
115 | end
116 |
117 | def mute_redis_command_error(&block)
118 | block.call
119 | rescue Redis::CommandError
120 | end
121 |
122 | def escape_key(key)
123 | key.gsub("#", "%23")
124 | end
125 |
126 | def clients_column_description(col)
127 | # https://redis.io/commands/client-list
128 | @clients_column_description ||= {
129 | id: "an unique 64-bit client ID (introduced in Redis 2.8.12).",
130 | addr: "address/port of the client",
131 | fd: "file descriptor corresponding to the socket",
132 | age: "total duration of the connection in seconds",
133 | idle: "idle time of the connection in seconds",
134 | flags: "client flags (see below)",
135 | db: "current database ID",
136 | sub: "number of channel subscriptions",
137 | psub: "number of pattern matching subscriptions",
138 | multi: "number of commands in a MULTI/EXEC context",
139 | qbuf: "query buffer length (0 means no query pending)",
140 | 'qbuf-f': "ree: free space of the query buffer (0 means the buffer is full)",
141 | obl: "output buffer length",
142 | oll: "output list length (replies are queued in this list when the buffer is full)",
143 | omem: "output buffer memory usage",
144 | events: "file descriptor events (see below)",
145 | cmd: "last command played",
146 | }
147 | @clients_column_description[col.to_sym]
148 | end
149 |
150 | def client_event_description(event)
151 | # https://redis.io/commands/client-list
152 | @client_event_description ||= {
153 | O: "the client is a slave in MONITOR mode",
154 | S: "the client is a normal slave server",
155 | M: "the client is a master",
156 | x: "the client is in a MULTI/EXEC context",
157 | b: "the client is waiting in a blocking operation",
158 | i: "the client is waiting for a VM I/O (deprecated)",
159 | d: "a watched keys has been modified - EXEC will fail",
160 | c: "connection to be closed after writing entire reply",
161 | u: "the client is unblocked",
162 | U: "the client is connected via a Unix domain socket",
163 | r: "the client is in readonly mode against a cluster node",
164 | A: "connection to be closed ASAP",
165 | N: "no specific flag set",
166 | }
167 | @client_event_description[event.to_sym]
168 | end
169 | end
170 | end
171 |
--------------------------------------------------------------------------------
/lib/redis_dashboard/public/ariato.css:
--------------------------------------------------------------------------------
1 | /* design_tokens */
2 | /* Colors */
3 |
4 | :root,
5 | .theme-light {
6 | --color-red-00: 255, 255, 255;
7 | --color-red-20: 253, 249, 249;
8 | --color-red-35: 252, 246, 245;
9 | --color-red-50: 250, 240, 239;
10 | --color-red-100: 244, 223, 219;
11 | --color-red-200: 229, 184, 177;
12 | --color-red-300: 211, 146, 137;
13 | --color-red-400: 193, 111, 101;
14 | --color-red-500: 174, 78, 69;
15 | --color-red-600: 151, 63, 58;
16 | --color-red-700: 130, 49, 47;
17 | --color-red-800: 109, 35, 36;
18 | --color-red-900: 89, 21, 26;
19 | --color-red-1000: 64, 4, 9;
20 | --color-grey-00: 255, 255, 255;
21 | --color-grey-20: 250, 250, 251;
22 | --color-grey-35: 247, 247, 248;
23 | --color-grey-50: 242, 242, 244;
24 | --color-grey-100: 226, 227, 231;
25 | --color-grey-200: 191, 195, 202;
26 | --color-grey-300: 157, 163, 173;
27 | --color-grey-400: 127, 134, 148;
28 | --color-grey-500: 100, 108, 124;
29 | --color-grey-600: 81, 88, 104;
30 | --color-grey-700: 64, 70, 85;
31 | --color-grey-800: 48, 52, 67;
32 | --color-grey-900: 32, 35, 49;
33 | --color-grey-1000: 12, 14, 28;
34 | --color-black: 0, 0.6353307795189879, 20.78828237830061;
35 |
36 | --color-bg: var(--color-grey-20);
37 | --color-body: var(--color-grey-500);
38 | }
39 |
40 | .theme-dark {
41 | --color-red-00: 36, 19, 16;
42 | --color-red-20: 42, 22, 19;
43 | --color-red-35: 47, 25, 22;
44 | --color-red-50: 51, 27, 24;
45 | --color-red-100: 67, 35, 31;
46 | --color-red-200: 100, 52, 47;
47 | --color-red-300: 135, 71, 63;
48 | --color-red-400: 172, 89, 80;
49 | --color-red-500: 210, 109, 98;
50 | --color-red-600: 223, 138, 127;
51 | --color-red-700: 233, 167, 158;
52 | --color-red-800: 243, 196, 189;
53 | --color-red-900: 250, 225, 222;
54 | --color-red-1000: 255, 255, 255;
55 | --color-grey-00: 15, 24, 37;
56 | --color-grey-20: 19, 28, 41;
57 | --color-grey-35: 22, 31, 44;
58 | --color-grey-50: 25, 34, 47;
59 | --color-grey-100: 36, 44, 58;
60 | --color-grey-200: 58, 66, 81;
61 | --color-grey-300: 81, 89, 105;
62 | --color-grey-400: 105, 114, 130;
63 | --color-grey-500: 130, 139, 156;
64 | --color-grey-600: 154, 161, 175;
65 | --color-grey-700: 178, 184, 194;
66 | --color-grey-800: 203, 207, 214;
67 | --color-grey-900: 229, 231, 234;
68 | --color-grey-1000: 255, 255, 255;
69 | --color-black: 0, 0, 0;
70 |
71 | --color-bg: var(--color-grey-20);
72 | --color-body: var(--color-grey-500);
73 | }
74 |
75 | @media (prefers-color-scheme: dark) {
76 | :not(.theme-light) {
77 | --color-red-00: 36, 19, 16;
78 | --color-red-20: 42, 22, 19;
79 | --color-red-35: 47, 25, 22;
80 | --color-red-50: 51, 27, 24;
81 | --color-red-100: 67, 35, 31;
82 | --color-red-200: 100, 52, 47;
83 | --color-red-300: 135, 71, 63;
84 | --color-red-400: 172, 89, 80;
85 | --color-red-500: 210, 109, 98;
86 | --color-red-600: 223, 138, 127;
87 | --color-red-700: 233, 167, 158;
88 | --color-red-800: 243, 196, 189;
89 | --color-red-900: 250, 225, 222;
90 | --color-red-1000: 255, 255, 255;
91 | --color-grey-00: 15, 24, 37;
92 | --color-grey-20: 19, 28, 41;
93 | --color-grey-35: 22, 31, 44;
94 | --color-grey-50: 25, 34, 47;
95 | --color-grey-100: 36, 44, 58;
96 | --color-grey-200: 58, 66, 81;
97 | --color-grey-300: 81, 89, 105;
98 | --color-grey-400: 105, 114, 130;
99 | --color-grey-500: 130, 139, 156;
100 | --color-grey-600: 154, 161, 175;
101 | --color-grey-700: 178, 184, 194;
102 | --color-grey-800: 203, 207, 214;
103 | --color-grey-900: 229, 231, 234;
104 | --color-grey-1000: 255, 255, 255;
105 | --color-black: 0, 0, 0;
106 |
107 | --color-bg: var(--color-grey-20);
108 | --color-body: var(--color-grey-500);
109 | }
110 | }
111 |
112 | body {
113 | background: rgba(var(--color-bg), 1);
114 | color: rgba(var(--color-body), 1);
115 | }
116 |
117 | :focus {
118 | outline: 2px solid rgba(var(--color-red-200), 1);
119 | }
120 |
121 | :root {
122 | --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
123 | --font-serif: Athelas, Cambria, Source Serif Pro, serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
124 | --font-mono: SF Mono, Menlo, Consolas, DejaVu Sans Mono, monospace;
125 |
126 | --base-font-size: var(--space-2x);
127 |
128 | /****************/
129 | /* font-weight */
130 |
131 | --font-weight-400: 400;
132 | --font-weight-700: 700;
133 |
134 | /****************/
135 | /* modular scale */
136 |
137 | --ratio: 1.25;
138 | --font-size-10: calc(var(--base-font-size) / var(--ratio) / var(--ratio) / var(--ratio) / var(--ratio)); /* -4 */
139 | --font-size-20: calc(var(--base-font-size) / var(--ratio) / var(--ratio) / var(--ratio)); /* -3 */
140 | --font-size-30: calc(var(--base-font-size) / var(--ratio) / var(--ratio)); /* -2 */
141 | --font-size-40: calc(var(--base-font-size) / var(--ratio)); /* -1 */
142 | --font-size-50: var(--base-font-size);
143 | --font-size-60: calc(var(--base-font-size) * var(--ratio)); /* +1 */
144 | --font-size-70: calc(var(--base-font-size) * var(--ratio) * var(--ratio)); /* +2 */
145 | --font-size-80: calc(var(--base-font-size) * var(--ratio) * var(--ratio) * var(--ratio)); /* +4 */
146 | --font-size-90: calc(var(--base-font-size) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio)); /* +5 */
147 | --font-size-100: calc(var(--base-font-size) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio)); /* +6 */
148 | --font-size-110: calc(var(--base-font-size) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio)); /* +7 */
149 | --font-size-120: calc(var(--base-font-size) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio)); /* +8 */
150 | --font-size-130: calc(var(--base-font-size) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio)); /* +9 */
151 | --font-size-140: calc(var(--base-font-size) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio)); /* +10 */
152 | }
153 |
154 | html,
155 | body {
156 | font-size: var(--base-font-size);
157 | }
158 |
159 | body {
160 | font-family: var(--font-sans);
161 | font-weight: var(--font-weight-400);
162 | line-height: var(--space-3x);
163 | }
164 |
165 | :root {
166 | /* box-shadow */
167 | --box-shadow-s: 0 0 2px -1px rgba(var(--color-black), 0.05),
168 | 0 1px 2px 0 rgba(var(--color-black), 0.05);
169 | --box-shadow-m: 0 0 2px -1px rgba(var(--color-black), 0.05),
170 | 0 1px 2px 0 rgba(var(--color-black), 0.05),
171 | 0 2px 4px -2px rgba(var(--color-black), 0.05);
172 | --box-shadow-l: 0 2px 2px -1px rgba(var(--color-black), 0.05),
173 | 0 4px 4px -2px rgba(var(--color-black), 0.05),
174 | 0 8px 16px -4px rgba(var(--color-black), 0.05),
175 | 0 16px 32px -8px rgba(var(--color-black), 0.05);
176 | /* inset box shadow */
177 | --box-inset-shadow-s: 0 1px 1px 0 rgba(var(--color-black), 0.04) inset;
178 | --box-inset-shadow-m: 0 1px 1px 0px rgba(var(--color-black), 0.04) inset,
179 | 0 2px 2px 1px rgba(var(--color-black), 0.04) inset;
180 | --box-inset-shadow-l: 0 1px 1px 0px rgba(var(--color-black), 0.04) inset,
181 | 0 2px 2px 1px rgba(var(--color-black), 0.04) inset,
182 | 0 2px 8px 2px rgba(var(--color-black), 0.04) inset;
183 | /* text shadow */
184 | --text-shadow-s: 0 1px 1px rgba(var(--color-black), 0.04);
185 | --text-shadow-m: 0 1px 1px rgba(var(--color-black), 0.04),
186 | 0 2px 2px rgba(var(--color-black), 0.04);
187 | --text-shadow-l: 0 1px 1px rgba(var(--color-black), 0.04),
188 | 0 2px 2px rgba(var(--color-black), 0.04),
189 | 0 4px 4px rgba(var(--color-black), 0.04);
190 | }
191 | /* shape */
192 |
193 | :root {
194 | /* border radius */
195 | --border-radius-s: calc(var(--space) * 0.25);
196 | --border-radius-m: var(--space-1-2);
197 | --border-radius-l: calc(var(--space) * 1);
198 | }
199 | /* space */
200 |
201 | *,
202 | *::before,
203 | *::after {
204 | box-sizing: border-box;
205 | }
206 |
207 | :root {
208 | --space: 0.5rem;
209 | /* divided */
210 | --space-1-4: calc(var(--space) / 4);
211 | --space-1-2: calc(var(--space) / 2);
212 | /* multiple */
213 | --space-2x: calc(var(--space) * 2);
214 | --space-3x: calc(var(--space) * 3);
215 | --space-4x: calc(var(--space) * 4);
216 | --space-5x: calc(var(--space) * 5);
217 | --space-6x: calc(var(--space) * 6);
218 | --space-7x: calc(var(--space) * 7);
219 | --space-8x: calc(var(--space) * 8);
220 | --space-9x: calc(var(--space) * 9);
221 | --space-10x: calc(var(--space) * 10);
222 | --space-11x: calc(var(--space) * 11);
223 | --space-12x: calc(var(--space) * 12);
224 | --space-13x: calc(var(--space) * 13);
225 | --space-14x: calc(var(--space) * 14);
226 |
227 | --screen-s: 900px;
228 | --screen-m: 1200px;
229 | --screen-l: 1400px;
230 | }
231 |
232 | is-flow > * + * {
233 | margin-top: var(--space-3x);
234 | }
235 | /* time */
236 |
237 | /* use for animations */
238 |
239 | /* elements */
240 | /* text selection */
241 | ::-moz-selection {
242 | background: rgba(var(--color-red-100), 1);
243 | text-shadow: none;
244 | }
245 |
246 | ::selection {
247 | background: rgba(var(--color-red-100), 1);
248 | text-shadow: none;
249 | }
250 |
251 | /* paragraphs */
252 |
253 | p {
254 | margin: 0;
255 | max-width: 70ch;
256 | padding: calc(var(--space) - 2px) 0 calc(var(--space-2x) + 2px); /* adjust baseline */
257 | }
258 |
259 | p + :not(p) {
260 | margin-top: var(--space-3x);
261 | }
262 |
263 | strong {
264 | color: rgba(var(--color-grey-700), 1);
265 | font-weight: inherit;
266 | }
267 |
268 | hr {
269 | height: var(--space-3x);
270 | margin: 0;
271 | padding: var(--space-3x) 0 calc(var(--space-3x) - 1px);
272 |
273 | /* type */
274 | background: transparent;
275 | border: 0;
276 | box-shadow: 0 -1px 0 0 rgba(var(--color-grey-50), 1);
277 | }
278 |
279 | sub,
280 | sup {
281 | line-height: 0; /* prevent line height shift */
282 | }
283 |
284 | kbd {
285 | padding: 0 var(--space-1-2);
286 | background-color: rgba(var(--color-grey-50), 1);
287 | box-shadow:
288 | 0 0 0 1px rgba(var(--color-grey-300), 1),
289 | var(--box-shadow-m);
290 | border-radius: var(--space-1-2);
291 |
292 | /* type */
293 | color: rgba(var(--color-grey-700), 1);
294 | font-size: var(--font-size-40);
295 | line-height: var(--space-3x);
296 | white-space: nowrap;
297 | text-transform: uppercase;
298 | font-family: var(--font-mono);
299 | font-weight: 700;
300 | }
301 |
302 |
303 | /* components/grid_auto.css */
304 |
305 | .grid-auto {
306 | --gap: var(--space-3x);
307 | --col-min-width: calc(var(--space) * 30);
308 |
309 | display: grid;
310 | grid-gap: var(--gap, 0);
311 | grid-template-columns: repeat(auto-fit, minmax(var(--col-min-width), 1fr));
312 | width: 100%;
313 | padding: 0;
314 | }
315 |
316 | .grid-auto > * {
317 | list-style-type: none;
318 | margin: 0;
319 | }
320 |
321 | .grid-auto * + *,
322 | .grid-auto * + .card {
323 | margin-top: 0;
324 | }
325 |
326 | .grid-auto + * {
327 | margin-top: var(--space-3x);
328 | }
329 |
330 | /* FORMS */
331 |
332 | form > * + * {
333 | margin-top: var(--space-3x, 1em);
334 | }
335 |
336 | /* FIELDSET */
337 |
338 | legend {
339 | transform: translateY(-2px);
340 | margin: calc(var(--space-3x) * -1) 0 0;
341 | }
342 |
343 | label {
344 | display: block;
345 | line-height: var(--space-3x);
346 | min-height: var(--space-5x);
347 | font-weight: 400;
348 | color: rgba(var(--color-grey-700), 1);
349 | padding: calc(var(--space) - 2px) 0 2px;
350 | }
351 |
352 | input,
353 | select,
354 | textarea,
355 | output {
356 | display: block;
357 | width: 100%;
358 | background-color: rgba(var(--color-grey-35), 1);
359 | min-height: var(--space-5x);
360 | padding: calc(var(--space) - 1px) calc(var(--space) - 1px);
361 | margin: 2px 0 -2px;
362 |
363 | /* border */
364 | border: 1px solid rgba(var(--color-grey-200), 1);
365 | border-radius: var(--border-radius-m, 0);
366 | outline: 0 none;
367 | box-shadow: var(--box-inset-shadow-s);
368 |
369 | /* text */
370 | color: rgba(var(--color-grey-600), 1);
371 | font-size: var(--base-font-size);
372 | line-height: var(--space-3x);
373 | }
374 |
375 | input:focus,
376 | select:focus,
377 | textarea:focus,
378 | output:focus {
379 | outline: 0 none;
380 | border-color: rgba(var(--color-red-400), 1);
381 | box-shadow:
382 | 0 0 0 2px rgba(var(--color-red-200), 1),
383 | var(--box-shadow-s);
384 | }
385 |
386 | input:focus:not(:focus-visible),
387 | select:focus:not(:focus-visible),
388 | textarea:focus:not(:focus-visible),
389 | output:focus:not(:focus-visible) {
390 | outline: none;
391 | }
392 |
393 | input[disabled],
394 | select[disabled],
395 | textarea[disabled],
396 | output[disabled] {
397 | border-color: rgba(var(--color-grey-100), 1);
398 | color: rgba(var(--color-bg), 1);
399 | background-color: rgba(var(--color-grey-100), 1);
400 | box-shadow: none;
401 | cursor: not-allowed;
402 | opacity: 1;
403 | }
404 |
405 | input[readonly],
406 | select[readonly],
407 | textarea[readonly],
408 | output[readonly] {
409 | border: 1px dashed rgba(var(--color-grey-200), 1);
410 | cursor: not-allowed;
411 | color: rgba(var(--color-grey-500), 1);
412 | background-color: transparent;
413 | box-shadow: 0 0 0 0;
414 | }
415 |
416 | input:-internal-autofill-selected,
417 | select:-internal-autofill-selected,
418 | textarea:-internal-autofill-selected,
419 | output:-internal-autofill-selected {
420 | font-size: var(--base-font-size);
421 | background-color: rgba(var(--color-red-100), 1) !important;
422 | }
423 |
424 | label + input,
425 | label + textarea,
426 | label + select,
427 | label + output {
428 | margin-top: calc(var(--space) * -1);
429 | }
430 |
431 |
432 | /* Change Autocomplete styles in Chrome*/
433 | input:-webkit-autofill,
434 | input:-webkit-autofill:hover,
435 | input:-webkit-autofill:focus,
436 | textarea:-webkit-autofill,
437 | textarea:-webkit-autofill:hover,
438 | textarea:-webkit-autofill:focus,
439 | select:-webkit-autofill,
440 | select:-webkit-autofill:hover,
441 | select:-webkit-autofill:focus {
442 | border-color:rgba(var(--color-red-200), 1);
443 | font-size: var(--base-font-size);
444 | -webkit-text-fill-color:rgba(var(--color-red-700), 1);
445 | -webkit-box-shadow: 0 0 0px 1000px rgba(var(--color-red-100), 1) inset;
446 | box-shadow: 0 0 0px 1000px rgba(var(--color-red-100), 1) inset;
447 | transition: background-color 5000s ease-in-out 0s;
448 | }
449 |
450 | ::placeholder {
451 | color: rgba(var(--color-grey-300), 1);
452 | }
453 |
454 | /* special inputs */
455 |
456 | input[type="search"] {
457 | -webkit-appearance: none;
458 | }
459 |
460 | a:any-link {
461 | /* Selects any element that would be matched by :link or :visited */
462 | display: inline-flex;
463 | align-items: center;
464 | color: rgba(var(--color-red-500, inherit), 1);
465 | text-decoration: underline;
466 | }
467 |
468 | a:any-link:hover {
469 | color: rgba(var(--color-red-600), 1);
470 | }
471 |
472 | a:any-link > * + * {
473 | margin-left: var(--space);
474 | text-decoration: none;
475 | }
476 |
477 | /* external links */
478 | a:any-link[target="_blank"] {
479 | padding-right: 16px;
480 | position: relative;
481 | }
482 |
483 | /* Arrow to signify external links */
484 | a:any-link[target="_blank"]::after,
485 | a:any-link[target="_blank"]::before {
486 | content: "";
487 | display: block;
488 | box-sizing: border-box;
489 | position: absolute;
490 | right: 0;
491 | }
492 |
493 | a:any-link[target="_blank"]::before {
494 | background: currentColor;
495 | transform: rotate(-45deg);
496 | top: 12px;
497 | height: 2px;
498 | width: 12px;
499 | }
500 |
501 | a:any-link[target="_blank"]::after {
502 | width: 8px;
503 | height: 8px;
504 | border-right: 2px solid;
505 | border-top: 2px solid;
506 | top: 7px;
507 | }
508 |
509 | ul,
510 | ol,
511 | dl {
512 | margin: 0;
513 | padding: 6px 0 18px 24px;
514 | }
515 |
516 | ul ul,
517 | ol ol,
518 | ol ul {
519 | padding: 0 0 0 24px;
520 | }
521 |
522 | ul.is-nobullet {
523 | list-style-type: none;
524 | padding: 0;
525 | }
526 |
527 | dl {
528 | padding: 6px 0 18px 0;
529 | }
530 |
531 | dt {
532 | font-weight: var(--font-weight-700);
533 | color: rgba(var(--color-grey-700), 1);
534 | }
535 |
536 | dd {
537 | margin: 0;
538 | }
539 |
540 | dd + dt {
541 | margin-top: var(--space-3x)
542 | }
543 | /* audio video embed ... */
544 | table {
545 | width: 100%;
546 | padding: 0;
547 | border-spacing: 0;
548 | }
549 |
550 | thead {
551 | box-shadow: 0 -1px 0 0 rgba(var(--color-grey-100), 1) inset;
552 | }
553 |
554 | tfoot {
555 | box-shadow: 0 -1px 0 0 rgba(var(--color-grey-100), 1);
556 | }
557 |
558 | caption {
559 | color: rgba(var(--color-grey-500), 1);
560 | padding: 6px 0 18px;
561 | }
562 |
563 | th {
564 | text-transform: uppercase;
565 | font-weight: var(--font-weight-700);
566 | font-size: calc(var(--base-font-size) / var(--ratio) / var(--ratio));
567 | letter-spacing: 0.03em;
568 | transform: translateY(2px);
569 | padding: 12px 24px 12px;
570 | text-align: left;
571 | color: rgba(var(--color-grey-500), 1);
572 | }
573 |
574 | tbody tr {
575 | box-shadow: 0 -1px 0 0 rgba(var(--color-grey-50), 1) inset;
576 | }
577 |
578 | tbody tr:hover {
579 | background: rgba(var(--color-grey-50), 1);
580 | }
581 |
582 |
583 | tbody tr:nth-child(even) {
584 | background: rgba(var(--color-grey-35), 1);
585 | }
586 |
587 | tbody tr:nth-child(even):hover {
588 | background: rgba(var(--color-grey-50), 1);
589 | }
590 |
591 | td {
592 | color: rgba(var(--color-grey-500), 1);
593 | padding: 12px 24px;
594 | }
595 |
596 | th.is-right,
597 | td.is-right {
598 | text-align: right;
599 | }
600 |
601 | [role="alert"] {
602 | display: flex;
603 | align-items: center;
604 | color: rgba(var(--color-grey-600), 1);
605 | background: rgba(var(--color-grey-50), 1);
606 | border-left: var(--space-1-2) solid rgba(var(--color-grey-400), 1);
607 | border-radius: var(--border-radius-s, 0);
608 | box-shadow: var(--box-shadow-m);
609 | padding: calc(var(--space) * 1.5 ) var(--space-3x);
610 | }
611 |
612 | [role="alert"]:empty {
613 | display: none;
614 | }
615 |
616 | [role="alert"] a:any-link {
617 | color: inherit;
618 | text-decoration: underline;
619 | }
620 |
621 | [role="alert"] a:any-link:hover {
622 | color: rgba(var(--color-grey-800), 1);
623 | }
624 |
625 | [role="alert"] p {
626 | color: inherit;
627 | font-family: var(--font-sans);
628 | font-size: 1rem;
629 | padding: 0;
630 | max-width: none;
631 | }
632 |
633 | [role="alert"] + * {
634 | margin-top: var(--space-6x);
635 | }
636 |
637 | /* flow */
638 | * + [role="alert"] {
639 | margin-top: var(--space-3x);
640 | }
641 |
642 | .badge {
643 | display: inline-block;
644 | height: var(--space-2x);
645 | padding: 0 var(--space-1-2);
646 | position: relative;
647 | background: rgba(var(--color-grey-50), 1);
648 | border-radius: var(--border-radius-m, 0);
649 | box-shadow: var(--box-shadow-s);
650 |
651 | /* text */
652 | color: rgba(var(--color-grey-700), 1);
653 | font-size: var(--font-size-30);
654 | font-weight: var(--font-weight-400);
655 | line-height: var(--space-2x);
656 | text-decoration: none;
657 | }
658 |
659 | .badge > svg {
660 | width: 12px;
661 | height: 12px;
662 | min-width: 12px;
663 | margin: 2px;
664 | vertical-align: middle;
665 | }
666 |
667 | .card {
668 | background: rgba(var(--color-grey-00), 1);
669 | border: 1px solid rgba(var(--color-grey-35), 1);
670 | border-radius: var(--border-radius-l, 0);
671 | box-shadow: var(--box-shadow-s);
672 | padding: calc(var(--space-3x) - 1px) calc(var(--space-3x) - 1px) calc(var(--space-3x) - 1px);
673 | list-style-type: none;
674 | overflow: hidden;
675 | }
676 |
677 | .card > header {
678 | display: flex;
679 | align-items: center;
680 | min-height: var(--space-6x);
681 | width: calc(100% + var(--space-6x));
682 | border-radius: var(--border-radius-m, 0) var(--border-radius-m, 0) 0 0;
683 | padding: var(--space) var(--space-3x);
684 | margin: calc(var(--space-3x) * -1) calc(var(--space-3x) * -1) calc(var(--space-3x) * 1);
685 | box-shadow: 0 1px 0 0 rgba(var(--color-grey-50), 1);
686 | color: rgba(var(--color-grey-500), 1);
687 | }
688 |
689 | .card > header > * {
690 | margin: 0 var(--space-1-2);
691 | padding: 0;
692 | text-transform: none;
693 | letter-spacing: 0;
694 | font-size: var(--font-size-50);
695 | line-height: var(--space-3x);
696 | }
697 |
698 | .card > picture {
699 | margin: calc(var(--space-3x) * -1) calc(var(--space-3x) * -1) calc(var(--space-3x) * 1);
700 | width: calc(100% + var(--space-6x));
701 | }
702 |
703 | .card > footer {
704 | background: rgba(var(--color-grey-35), 1);
705 | box-shadow: 0 -1px 0 0 rgba(var(--color-grey-50), 1);
706 | border-radius: 0 0 var(--border-radius-m, 0) var(--border-radius-m, 0);
707 | margin: var(--space-3x) calc(var(--space-3x) * -1) calc(var(--space-3x) * -1);
708 | padding: var(--space-2x) calc(var(--space-3x) - 1px);
709 | width: calc(100% + var(--space-6x));
710 | }
711 |
712 | .card > aside {
713 | background: rgba(var(--color-grey-50), 1);
714 | min-width: 300px;
715 | padding: var(--space-3x);
716 | }
717 |
718 | * + .card {
719 | margin-top: var(--space-3x);
720 | }
721 |
722 | /* Aria states last loaded */
723 | *[hidden=true] {
724 | display: none;
725 | }
726 |
727 | /* hide visually but make it available to assistive technology. */
728 | .sr-only {
729 | border: 0 !important;
730 | clip: rect(1px, 1px, 1px, 1px) !important;
731 | -webkit-clip-path: inset(50%) !important;
732 | clip-path: inset(50%) !important;
733 | height: 1px !important;
734 | overflow: hidden !important;
735 | padding: 0 !important;
736 | position: absolute !important;
737 | width: 1px !important;
738 | white-space: nowrap !important;
739 | }
--------------------------------------------------------------------------------