├── .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 | 8 | 9 | <% end %> 10 | 11 |
<%= value %>
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 | 8 | 9 | <% end %> 10 | 11 |
<%= value %>
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 | 8 | 9 | 10 | <% end %> 11 | 12 |
<%= name %><%= value %>
13 | -------------------------------------------------------------------------------- /lib/redis_dashboard/views/key/zset.erb: -------------------------------------------------------------------------------- 1 |

Data

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <% client.connection.call([:zrange, key, 0, -1, "WITHSCORES"]).each_slice(2) do |value, score| %> 10 | 11 | 12 | 13 | 14 | <% end %> 15 | 16 |
ScoreValue
<%= score %><%= value %>
17 | -------------------------------------------------------------------------------- /lib/redis_dashboard/views/info.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | <% for (key,value) in info %> 4 | 5 | 6 | 7 | 8 | <% end %> 9 |
<%= key %><%= value %>
10 |
11 |
12 | 13 | Redis documentation 14 | 15 |
16 | -------------------------------------------------------------------------------- /lib/redis_dashboard/views/config.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | <% for (key, value) in config %> 4 | 5 | 6 | 7 | 8 | <% end %> 9 |
<%= key %><%= value %>
10 |
11 |
12 | 13 | Redis config documentation 14 | 15 |
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 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% for (db, stats) in keyspace %> 13 | 14 | 17 | 18 | 19 | 20 | 21 | <% end %> 22 |
dbkeysexpiresavg_ttl
15 | "><%= db %> 16 | <%= stats["keys"] %><%= stats["expires"] %><%= stats["avg_ttl"] %>
23 |
24 | -------------------------------------------------------------------------------- /lib/redis_dashboard/views/memory.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <% for (key,value) in stats %> 5 | 6 | 7 | 16 | 17 | <% end %> 18 | 19 |
<%= key %> 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 |
20 |
21 |
22 | 23 | Redis memory stats documentation 24 | 25 |
26 | -------------------------------------------------------------------------------- /lib/redis_dashboard/views/stats.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <% for (key,hash) in stats %> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <% end %> 22 | 23 |
CommandCallsAVG timeTotal timeImpact
<%= key %><%= hash["calls"] %><%== format_usec hash["usec_per_call"] %><%== format_usec hash["usec"] %><%== format_impact_percentage hash["impact"] %>
24 |
25 | -------------------------------------------------------------------------------- /lib/redis_dashboard/views/clients.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <% for key in (keys = clients.first.keys) %> 5 | 6 | <% end %> 7 | 8 | 9 | <% for client in clients %> 10 | 11 | <% for key in keys %> 12 | <% if key == "events" %> 13 | 14 | <% else %> 15 | 16 | <% end %> 17 | <% end %> 18 | 19 | <% end %> 20 | 21 |
<%= key %>
<%= client[key] %><%= client[key] %>
22 |
23 |
24 | 25 | Redis client list documentation 26 | 27 |
-------------------------------------------------------------------------------- /lib/redis_dashboard/views/keys.erb: -------------------------------------------------------------------------------- 1 |
2 |
" method="get"> 3 | " placeholder="Filter key names with patterns such as foo*"/> 4 |
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 | 21 | 22 | <% end %> 23 | 24 |
"><%= key %>
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 |
6 | "><%= client.host %> (<%= info["role"] %>) 7 |
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 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | <% for cmd in commands %> 17 | 18 | 19 | 20 | 21 | 22 | 23 | <% end %> 24 | 25 |
CommandIDAtDuration
<%= cmd.command.join(" ") %><%= cmd.id %><%= epoch_to_short_date_time cmd.timestamp %><%== format_usec cmd.microseconds %>
26 |
27 |
28 | 29 | Redis slowlog documentation 30 | 31 |
32 | -------------------------------------------------------------------------------- /lib/redis_dashboard/views/key/metadata.erb: -------------------------------------------------------------------------------- 1 |

Metadata

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | <% if key_type == "list" %> 41 | 42 | 43 | 44 | 45 | <% end %> 46 | 47 |
Key<%= key %>
Type<%= key_type = client.connection.type(key) %>
Bytes<%= mute_redis_command_error { client.connection.memory(:usage, key) } %>
TTL<%= client.connection.ttl(key) %>
Object refcount<%= client.connection.object("REFCOUNT", key) %>
Object encoding<%= client.connection.object("ENCODING", key) %>
Object idletime<%= client.connection.object("IDLETIME", key) %>
Length<%= client.connection.llen(key) %>
-------------------------------------------------------------------------------- /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 | ![Redis dashboard](https://github.com/BaseSecrete/redis_dashboard/blob/master/screenshot.jpg) 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 |

16 | ">Redis Dashboard 17 |

18 | <% if params[:server] %> 19 | <%= page_title %> 20 | <% end %> 21 | <% if params[:server] %> 22 | 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 | } --------------------------------------------------------------------------------