diff --git a/CLAUDE.md b/CLAUDE.md index 8328a5e..1060ed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,13 @@ renderings only for unbuilt sites) and credit format. photo credit and incidents with sources. Licensed CC BY 4.0, with the attribution inside the file. Feeds and dataset send `Access-Control-Allow-Origin: *`. +- Reachability: `/:locale/status` and `/status.json` (`Bingo::Status`), + plus a block on monitored datacenter pages. They read the + `bingo_monitor_*` tables that asxpio's `bin/monitor` writes (TCP probes + from Finland, 5-minute buckets, control hosts in Russia). States: up, + degraded, down (nothing answered for two buckets), unknown (worker stale, + or under half the controls answering). Not incidents: never cross out a + cell from this alone. - `config.ru`: `www.` → `BINGO_HOST` 301. - `views/`: `layout.erb` (CSS and SEO head), `index.erb` (card, provider filter, dialogs), `dc.erb`, partials. `assets/`: icons. diff --git a/app.rb b/app.rb index 5d4875c..6d18197 100644 --- a/app.rb +++ b/app.rb @@ -181,6 +181,24 @@ class BingoWeb < Sinatra::Base def feed_time(i) = Time.utc(i.occurred_on.year, i.occurred_on.month, i.occurred_on.day) + # Bar colour for an answered share (nil = no data). + def avail_class(a) + return '' if a.nil? + a >= 0.8 ? 'up' : (a.zero? ? 'down' : 'degraded') + end + + def status_report(only: nil) = Bingo::Status.available? ? Bingo::Status.load(only: only) : nil + + def status_json(report) + { checked_at: report&.checked_at&.utc&.iso8601, monitor: report&.state, + controls: report&.controls && { ok: report.controls[0], checked: report.controls[1] }, + datacenters: (report&.sites || []).map do |s| + { slug: s.dc.slug, rank: s.dc.position, state: s.state, availability: s.availability&.round(3), + rtt_ms: s.rtt_ms, targets: s.targets, hourly: s.history.map { |a| a&.round(3) } } + end, + unmonitored: (report&.unmonitored || []).map(&:slug) } + end + def preferred_locale langs = request.env['HTTP_ACCEPT_LANGUAGE'].to_s.downcase.scan(/([a-z]{2})(?:-[a-z]+)?(?:;q=([\d.]+))?/) langs.sort_by { |_, q| -(q || '1').to_f }.map(&:first).find { |l| Bingo::LOCALES.include?(l) } || 'en' @@ -252,10 +270,29 @@ class BingoWeb < Sinatra::Base @path_for = ->(l) { dc_path(@dc, l) } @og = @dc if @dc.photo_key @ld = dc_json_ld(@dc) + @status = status_report(only: @dc.id)&.sites&.first cache_control :public, max_age: 60 erb :dc end + # Reachability from asxpio's monitor; not incidents (see Bingo::Status). + get %r{/(ru|en|uk)/status} do |locale| + @locale = locale + @report = status_report + @title = t(:status_title) + @desc = snippet(t(:status_lead)) + @path_for = ->(l) { "/#{l}/status" } + cache_control :public, max_age: 60 + erb :status + end + + get '/status.json' do + content_type 'application/json', charset: 'utf-8' + headers 'Access-Control-Allow-Origin' => '*' + cache_control :public, max_age: 60 + JSON.pretty_generate(status_json(status_report)) + end + # Incident feeds per locale: RSS 2.0 and JSON Feed 1.1, newest first. get %r{/(ru|en|uk)/feed\.xml} do |locale| @locale = locale diff --git a/lib/bingo.rb b/lib/bingo.rb index 7ca2aca..7be2c78 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -72,7 +72,19 @@ module Bingo dc_since: 'Работает с %d года.', dc_rank: '№ %{pos} по мощности среди крупнейших дата-центров России.', dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты', - back: '← К карточке', dc_page: 'Страница ЦОДа →' + back: '← К карточке', dc_page: 'Страница ЦОДа →', + status_link: 'Доступность ЦОДов', status_title: 'Доступность ЦОДов России — сетевой мониторинг | Бинго ЦОДов', + status_h1: 'Доступность', status_accent: 'ЦОДов', + status_lead: 'Раз в минуту мы открываем TCP-соединение с публичными адресами, которые открытые источники связывают с конкретной площадкой. Проверка идёт из Финляндии: российская фильтрация и отключения внешних каналов могут выглядеть как авария, поэтому параллельно проверяются контрольные узлы в России.', + status_note: 'Это не подтверждённые инциденты: клетка на карточке зачёркивается только по публичному источнику.', + status_states: { 'up' => 'доступен', 'degraded' => 'частично', 'down' => 'не отвечает', 'unknown' => 'нет данных' }, + status_controls: 'Контрольные узлы в России: %{ok} из %{n} отвечают.', + status_stale: 'Монитор давно не присылал данных — состояние неизвестно.', + status_vantage: 'Большинство контрольных узлов в России не отвечает: проблема, скорее всего, на пути из-за границы, а не на площадках.', + status_checked: 'Последняя проверка: %{time} UTC.', status_never: 'Проверок ещё не было.', + status_unmonitored: 'Без мониторинга', status_unmonitored_note: 'Действующие ЦОДы, для которых нет публичных адресов, надёжно привязанных к площадке (обычно собственные ЦОДы Яндекса, VK, Сбера).', + status_cols: { 'dc' => 'ЦОД', 'state' => 'Сейчас', 'hours' => '24 часа', 'rtt' => 'мс', 'targets' => 'адр.' }, + status_dc: 'Доступность сети', status_hour: '%{h} ч назад: %{p}' }, 'en' => { seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters', @@ -104,7 +116,19 @@ module Bingo dc_since: 'In service since %d.', dc_rank: "No. %{pos} by power among Russia's largest datacenters.", dc_latest: 'Latest reported incident (%{date}): %{title}', incidents: 'Incidents', - back: '← Back to the card', dc_page: 'Datacenter page →' + back: '← Back to the card', dc_page: 'Datacenter page →', + status_link: 'Datacenter reachability', status_title: "Russian datacenter reachability — network monitor | Datacenter Bingo", + status_h1: 'Datacenter', status_accent: 'reachability', + status_lead: 'Once a minute we open a TCP connection to public addresses that open sources tie to a specific site. Probes run from Finland: Russian filtering and cuts to international links can look like an outage, so control hosts inside Russia are checked alongside.', + status_note: 'These are not confirmed incidents: a cell on the card is crossed out only on a public source.', + status_states: { 'up' => 'reachable', 'degraded' => 'partial', 'down' => 'not answering', 'unknown' => 'no data' }, + status_controls: 'Control hosts in Russia: %{ok} of %{n} answering.', + status_stale: 'The monitor has not reported for a while — state unknown.', + status_vantage: 'Most control hosts in Russia are not answering: the problem is most likely on the path from abroad, not at the sites.', + status_checked: 'Last check: %{time} UTC.', status_never: 'No checks yet.', + status_unmonitored: 'Not monitored', status_unmonitored_note: 'Operational datacenters with no public address reliably tied to the site (mostly Yandex, VK and Sber in-house sites).', + status_cols: { 'dc' => 'Datacenter', 'state' => 'Now', 'hours' => '24 hours', 'rtt' => 'ms', 'targets' => 'addr.' }, + status_dc: 'Network reachability', status_hour: '%{h} h ago: %{p}' }, 'uk' => { seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії', @@ -136,7 +160,19 @@ module Bingo dc_since: 'Працює з %d року.', dc_rank: '№ %{pos} за потужністю серед найбільших дата-центрів Росії.', dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти', - back: '← До картки', dc_page: 'Сторінка ЦОДу →' + back: '← До картки', dc_page: 'Сторінка ЦОДу →', + status_link: 'Доступність ЦОДів', status_title: 'Доступність ЦОДів Росії — мережевий моніторинг | Бінго ЦОДів', + status_h1: 'Доступність', status_accent: 'ЦОДів', + status_lead: 'Раз на хвилину ми відкриваємо TCP-з’єднання з публічними адресами, які відкриті джерела пов’язують із конкретним майданчиком. Перевірка йде з Фінляндії: російська фільтрація та відключення зовнішніх каналів можуть виглядати як аварія, тому паралельно перевіряються контрольні вузли в Росії.', + status_note: 'Це не підтверджені інциденти: клітинка на картці закреслюється лише за публічним джерелом.', + status_states: { 'up' => 'доступний', 'degraded' => 'частково', 'down' => 'не відповідає', 'unknown' => 'немає даних' }, + status_controls: 'Контрольні вузли в Росії: %{ok} з %{n} відповідають.', + status_stale: 'Монітор давно не надсилав даних — стан невідомий.', + status_vantage: 'Більшість контрольних вузлів у Росії не відповідає: проблема, найімовірніше, на шляху з-за кордону, а не на майданчиках.', + status_checked: 'Остання перевірка: %{time} UTC.', status_never: 'Перевірок ще не було.', + status_unmonitored: 'Без моніторингу', status_unmonitored_note: 'Діючі ЦОДи, для яких немає публічних адрес, надійно прив’язаних до майданчика (здебільшого власні ЦОДи Яндекса, VK, Сбера).', + status_cols: { 'dc' => 'ЦОД', 'state' => 'Зараз', 'hours' => '24 години', 'rtt' => 'мс', 'targets' => 'адр.' }, + status_dc: 'Доступність мережі', status_hour: '%{h} год тому: %{p}' } }.freeze @@ -268,6 +304,90 @@ module Bingo end end + # Network reachability from asxpio's monitor (bin/monitor there writes the + # bingo_monitor_* tables). Each 5-minute bucket counts TCP probes sent and + # answered per datacenter, plus probes to control hosts in Russia that + # belong to no listed site. + module Status + BUCKET = 300 + STALE = 900 # newest worker bucket older than this: nothing is current + HOURS = 24 + + Site = Struct.new(:dc, :state, :availability, :rtt_ms, :history, :targets) + Report = Struct.new(:sites, :unmonitored, :checked_at, :controls, :state) do + def counts = sites.map(&:state).tally + end + + module_function + + def available? = DB.connection.table_exists?(:bingo_monitor_samples) + + # A bucket is trusted when at least half the control hosts answered; with + # none answering, a dark site may just mean our vantage point lost Russia. + def vantage_ok?(bucket) + bucket[:control_checks].zero? || bucket[:control_ok] * 2 >= bucket[:control_checks] + end + + # state: up (≥80 % of probes answered), degraded, down (nothing answered + # in the last two buckets), unknown (worker stale, vantage lost, or the + # site wasn't probed in the newest bucket). + def site_state(rows, latest, previous, trusted) + return 'unknown' unless latest && trusted + cur = rows[latest] or return 'unknown' + prev = previous && rows[previous] + return 'down' if cur[:ok].zero? && (prev.nil? || prev[:ok].zero?) + cur[:ok] >= cur[:checks] * 0.8 ? 'up' : 'degraded' + end + + # only: a datacenter id, for one datacenter page. + def load(now = Time.now.utc, hours: HOURS, only: nil) + db = DB.connection + since = now - (hours * 3600) + buckets = db[:bingo_monitor_buckets].where { bucket_at >= since }.order(:bucket_at).all + latest = buckets.last + fresh = latest && now - latest[:bucket_at] < STALE + trusted = fresh && vantage_ok?(latest) + latest_at = fresh ? latest[:bucket_at] : nil + previous_at = fresh && buckets.size > 1 ? buckets[-2][:bucket_at] : nil + bad_hours = buckets.reject { |b| vantage_ok?(b) }.map { |b| hour_index(b[:bucket_at], now, hours) }.to_set + + samples = db[:bingo_monitor_samples].where { bucket_at >= since } + targets = db[:bingo_monitor_targets].where(active: true).exclude(datacenter_id: nil) + dcs = Datacenter.order(:position, :id) + samples, targets, dcs = [samples.where(datacenter_id: only), targets.where(datacenter_id: only), dcs.where(id: only)] if only + samples = samples.all.group_by { |r| r[:datacenter_id] } + targets = targets.group_and_count(:datacenter_id).to_hash(:datacenter_id, :count) + dcs = dcs.all + monitored, unmonitored = dcs.partition { |dc| targets.key?(dc.id) } + + sites = monitored.map do |dc| + rows = (samples[dc.id] || []).to_h { |r| [r[:bucket_at], r] } + cur = latest_at && rows[latest_at] + Site.new(dc, site_state(rows, latest_at, previous_at, trusted), + cur && cur[:checks].positive? ? cur[:ok].fdiv(cur[:checks]) : nil, + cur && cur[:ok].positive? ? (cur[:rtt_ms_sum] / cur[:ok]) : nil, + history(rows.values, now, hours, bad_hours), targets[dc.id]) + end + Report.new(sites, unmonitored.select(&:operational?), latest && latest[:bucket_at], + latest && [latest[:control_ok], latest[:control_checks]], + !fresh ? 'stale' : (trusted ? 'ok' : 'vantage')) + end + + def hour_index(time, now, hours) = hours - 1 - ((now - time) / 3600).floor + + # Answered share per hour, oldest first; nil = no data or untrusted hour. + def history(rows, now, hours, bad_hours) + per = Array.new(hours) { [0, 0] } + rows.each do |r| + i = hour_index(r[:bucket_at], now, hours) + next unless i.between?(0, hours - 1) + per[i][0] += r[:ok] + per[i][1] += r[:checks] + end + per.each_with_index.map { |(ok, n), i| n.zero? || bad_hours.include?(i) ? nil : ok.fdiv(n) } + end + end + # IndexNow pings are sent by the asxp.io admin; this site only serves the # key file at /.txt to prove they come from us. module IndexNow diff --git a/test/schema.sql b/test/schema.sql index 3e68d23..9e31021 100644 --- a/test/schema.sql +++ b/test/schema.sql @@ -1,6 +1,7 @@ --- bingo tables as created by the asxpio migrations (004–016), for tests. +-- bingo tables as created by the asxpio migrations (004–017), for tests. -- Refresh when asxpio adds a bingo migration: run them into an empty DB and --- pg_dump -s -O -x -t bingo_datacenters -t bingo_incidents. +-- pg_dump -s -O -x -t bingo_datacenters -t bingo_incidents -t 'bingo_monitor_*'. + CREATE TABLE public.bingo_datacenters ( id integer NOT NULL, slug text NOT NULL, @@ -61,6 +62,45 @@ ALTER TABLE public.bingo_incidents ALTER COLUMN id ADD GENERATED BY DEFAULT AS I CACHE 1 ); +CREATE TABLE public.bingo_monitor_buckets ( + bucket_at timestamp without time zone NOT NULL, + rounds integer DEFAULT 0 NOT NULL, + control_checks integer DEFAULT 0 NOT NULL, + control_ok integer DEFAULT 0 NOT NULL +); + +CREATE TABLE public.bingo_monitor_samples ( + datacenter_id integer NOT NULL, + bucket_at timestamp without time zone NOT NULL, + checks integer DEFAULT 0 NOT NULL, + ok integer DEFAULT 0 NOT NULL, + rtt_ms_sum integer DEFAULT 0 NOT NULL +); + +CREATE TABLE public.bingo_monitor_targets ( + id integer NOT NULL, + datacenter_id integer, + host text NOT NULL, + port integer DEFAULT 443 NOT NULL, + label text, + source_url text, + active boolean DEFAULT true NOT NULL, + last_ok boolean, + last_rtt_ms integer, + last_checked_at timestamp without time zone, + last_change_at timestamp without time zone, + created_at timestamp without time zone NOT NULL +); + +ALTER TABLE public.bingo_monitor_targets ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.bingo_monitor_targets_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + ALTER TABLE ONLY public.bingo_datacenters ADD CONSTRAINT bingo_datacenters_pkey PRIMARY KEY (id); @@ -70,9 +110,25 @@ ALTER TABLE ONLY public.bingo_datacenters ALTER TABLE ONLY public.bingo_incidents ADD CONSTRAINT bingo_incidents_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.bingo_monitor_buckets + ADD CONSTRAINT bingo_monitor_buckets_pkey PRIMARY KEY (bucket_at); + +ALTER TABLE ONLY public.bingo_monitor_samples + ADD CONSTRAINT bingo_monitor_samples_pkey PRIMARY KEY (datacenter_id, bucket_at); + +ALTER TABLE ONLY public.bingo_monitor_targets + ADD CONSTRAINT bingo_monitor_targets_pkey PRIMARY KEY (id); + CREATE INDEX bingo_incidents_datacenter_id_index ON public.bingo_incidents USING btree (datacenter_id); +CREATE INDEX bingo_monitor_targets_datacenter_id_index ON public.bingo_monitor_targets USING btree (datacenter_id); + ALTER TABLE ONLY public.bingo_incidents ADD CONSTRAINT bingo_incidents_datacenter_id_fkey FOREIGN KEY (datacenter_id) REFERENCES public.bingo_datacenters(id) ON DELETE CASCADE; +ALTER TABLE ONLY public.bingo_monitor_samples + ADD CONSTRAINT bingo_monitor_samples_datacenter_id_fkey FOREIGN KEY (datacenter_id) REFERENCES public.bingo_datacenters(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.bingo_monitor_targets + ADD CONSTRAINT bingo_monitor_targets_datacenter_id_fkey FOREIGN KEY (datacenter_id) REFERENCES public.bingo_datacenters(id) ON DELETE CASCADE; diff --git a/test/status_test.rb b/test/status_test.rb new file mode 100644 index 0000000..6022189 --- /dev/null +++ b/test/status_test.rb @@ -0,0 +1,93 @@ +require_relative 'test_helper' + +class StatusTest < Minitest::Test + include Rack::Test::Methods + + def app = BingoWeb + + NOW = Time.utc(2026, 10, 18, 12, 2) + B = Time.utc(2026, 10, 18, 12, 0) # newest bucket at NOW + + def setup + skip 'TEST_DATABASE_URL not set' unless TestDb.available? + @db = DB.connection + %i[bingo_monitor_buckets bingo_monitor_samples bingo_monitor_targets bingo_incidents bingo_datacenters].each { |t| @db[t].delete } + @up, @down, @part, @none = %w[up down part none].each_with_index.map do |s, i| + Bingo::Datacenter.create(slug: "dc-#{s}", position: i + 1, name_ru: "ЦОД #{s}", city_ru: 'Москва', + operator: 'Op', status: 'operational') + end + [@up, @down, @part].each { |dc| @db[:bingo_monitor_targets].insert(datacenter_id: dc.id, host: "h-#{dc.slug}", port: 443, created_at: B) } + @db[:bingo_monitor_targets].insert(datacenter_id: nil, host: 'ctl', port: 80, created_at: B) + end + + def bucket(at, control_ok: 4, control_checks: 4) + @db[:bingo_monitor_buckets].insert(bucket_at: at, rounds: 5, control_checks: control_checks, control_ok: control_ok) + end + + def sample(dc, at, ok, checks = 5) + @db[:bingo_monitor_samples].insert(datacenter_id: dc.id, bucket_at: at, checks: checks, ok: ok, rtt_ms_sum: ok * 40) + end + + def two_buckets(base = B, **controls) + [base - 300, base].each do |at| + bucket(at, **controls) + sample(@up, at, 5) + sample(@down, at, 0) + sample(@part, at, 2) + end + end + + def states(report) = report.sites.to_h { |s| [s.dc.slug, s.state] } + + def test_states_from_newest_buckets + two_buckets + r = Bingo::Status.load(NOW) + assert_equal({ 'dc-up' => 'up', 'dc-down' => 'down', 'dc-part' => 'degraded' }, states(r)) + assert_equal ['dc-none'], r.unmonitored.map(&:slug) + assert_equal 40, r.sites.first.rtt_ms + assert_equal 'ok', r.state + end + + def test_one_dark_bucket_after_a_good_one_is_not_yet_down + bucket(B - 300); sample(@down, B - 300, 5) + bucket(B); sample(@down, B, 0) + assert_equal 'degraded', states(Bingo::Status.load(NOW))['dc-down'] + end + + def test_lost_vantage_makes_everything_unknown + two_buckets(control_ok: 1, control_checks: 4) + r = Bingo::Status.load(NOW) + assert_equal %w[unknown], states(r).values.uniq + assert_equal 'vantage', r.state + assert_nil r.sites.first.history.last, 'untrusted hour shows no data' + end + + def test_stale_worker_makes_everything_unknown + two_buckets + r = Bingo::Status.load(NOW + 3600) + assert_equal %w[unknown], states(r).values.uniq + assert_equal 'stale', r.state + end + + def test_status_page_and_json + two_buckets(Time.at(Time.now.to_i / 300 * 300).utc) # pages use the real clock + get '/ru/status' + assert last_response.ok? + assert_includes last_response.body, 'не отвечает' + assert_includes last_response.body, 'ЦОД none' # unmonitored list + get '/status.json' + data = JSON.parse(last_response.body) + assert_equal %w[dc-up dc-down dc-part], data['datacenters'].map { |d| d['slug'] } + assert_equal ['dc-none'], data['unmonitored'] + get '/en/dc/dc-up' + assert_includes last_response.body, 'Network reachability' + get '/en/dc/dc-none' + refute_includes last_response.body, 'Network reachability' + end + + def test_status_page_before_any_round + get '/en/status' + assert last_response.ok? + assert_includes last_response.body, 'No checks yet.' + end +end diff --git a/views/_footer.erb b/views/_footer.erb index 63c0f06..037f0d9 100644 --- a/views/_footer.erb +++ b/views/_footer.erb @@ -2,6 +2,6 @@

<%= t(:footer) %>

<%= t(:credits_ai) %> <%== format(Rack::Utils.escape_html(t(:credits_hym)), link: 'hackyourmom') %>

-

RSS · JSON Feed · <%= t(:open_data) %>

+

RSS · JSON Feed · <%= t(:open_data) %> · <%= t(:status_link) %>

© <%= Date.today.year %> asxp.io · <%= t(:source_code) %>

diff --git a/views/_status_bars.erb b/views/_status_bars.erb new file mode 100644 index 0000000..78c96a6 --- /dev/null +++ b/views/_status_bars.erb @@ -0,0 +1,3 @@ + diff --git a/views/dc.erb b/views/dc.erb index a09c921..4954f32 100644 --- a/views/dc.erb +++ b/views/dc.erb @@ -15,6 +15,13 @@ <%== erb :_dc_meta, layout: false, locals: { dc: dc } %>

<%= dc_facts(dc).join(' ') %>

<% dc.about(@locale).each do |para| %>

<%= para %>

<% end %> + <% if @status %> +
+

<%= t(:status_dc) %>

+

<%= t(:status_states)[@status.state] %> · <%= t(:status_link) %>

+ <%== erb :_status_bars, layout: false, locals: { history: @status.history } %> +
+ <% end %>

<%= t(:incidents) %>

<%== erb :_dc_incidents, layout: false, locals: { dc: dc } %> diff --git a/views/layout.erb b/views/layout.erb index e231d24..cd81049 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -48,18 +48,18 @@ diff --git a/views/status.erb b/views/status.erb new file mode 100644 index 0000000..b4ae444 --- /dev/null +++ b/views/status.erb @@ -0,0 +1,62 @@ +<% r = @report %> +
+
+

<%= t(:status_h1) %> <%= t(:status_accent) %>

+ +
+ + +

<%= t(:status_lead) %>

+

<%= t(:status_note) %>

+ + <% if r.nil? || r.checked_at.nil? %> +

<%= t(:status_never) %>

+ <% else %> + <% if r.state == 'stale' %>

<%= t(:status_stale) %>

<% end %> + <% if r.state == 'vantage' %>

<%= t(:status_vantage) %>

<% end %> +

+ <%= format(t(:status_checked), time: r.checked_at.strftime('%d.%m.%Y %H:%M')) %> + <% if r.controls && r.controls[1].positive? %><%= format(t(:status_controls), ok: r.controls[0], n: r.controls[1]) %><% end %> + <% c = r.counts %> + <% %w[up degraded down unknown].each do |s| %><% if c[s] %> <%= t(:status_states)[s] %>: <%= c[s] %><% end %><% end %> +

+ + + + + + + + + + + <% r.sites.each do |s| %> + + + + + + + + + <% end %> + +
#<%= t(:status_cols)['dc'] %><%= t(:status_cols)['state'] %><%= t(:status_cols)['hours'] %><%= t(:status_cols)['rtt'] %><%= t(:status_cols)['targets'] %>
<%= s.dc.position %><%= s.dc.l(:name, @locale) %><%= s.dc.l(:city, @locale) %> · <%= s.dc.operator %><%= t(:status_states)[s.state] %><%== erb :_status_bars, layout: false, locals: { history: s.history } %><%= s.rtt_ms %><%= s.targets %>
+ <% end %> + + <% if r && r.unmonitored.any? %> +
+

<%= t(:status_unmonitored) %> (<%= r.unmonitored.size %>)

+

<%= t(:status_unmonitored_note) %>

+

+ <% r.unmonitored.each_with_index do |dc, i| %><%= ', ' if i.positive? %><%= dc.l(:name, @locale) %><% end %> +

+
+ <% end %> + + <%== erb :_footer, layout: false %> +