status.nixos.org: add channel status quick-view
Graham Christensen committed
Dec 30, 2019 at 20:50 UTC
58c7a00d1ec6f3b12e6ff585e480f2e7df439e59
4 files changed
+301
-6
delft/eris.nix
+1
-6
@@ -43,12 +43,7 @@ in {
43
virtualHosts."status.nixos.org" = {
44
enableACME = true;
45
forceSSL = true;
46
- root = pkgs.writeTextDir "index.html" ''
47
- <ul>
48
- <li><a href="/grafana">Grafana</a></li>
49
- <li><a href="/prometheus">Prometheus</a></li>
50
- </ul>
51
- '';
46
+ root = ./eris/status-page;
47
locations."/grafana/".proxyPass = "http://${config.services.grafana.addr}:${toString config.services.grafana.port}/";
48
locations."/prometheus".proxyPass = "http://${config.services.prometheus.listenAddress}";
49
};
delft/eris/status-page/index.html
new
+84
@@ -0,0 +1,84 @@
1
+<!DOCTYPE html>
2
+
3
+<html lang="en">
4
+
5
+ <head>
6
+ <title>NixOS Infra Status</title>
7
+
8
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
9
+ <meta http-equiv="X-UA-Compatible" content="IE=Edge" />
10
+
11
+ <script type="text/javascript" src="https://nixos.org/js/jquery.min.js"></script>
12
+ <script type="text/javascript" src="https://nixos.org/js/jquery-ui.min.js"></script>
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
14
+ <script type="text/javascript" src="https://nixos.org/bootstrap/js/bootstrap.min.js"></script>
15
+ <link rel="stylesheet" href="https://nixos.org/bootstrap/css/bootstrap.min.css" />
16
+ <link rel="stylesheet" href="https://nixos.org/bootstrap/css/bootstrap-responsive.min.css" />
17
+ <link rel="stylesheet" href="https://nixos.org/css/nixos-site.css" type="text/css" />
18
+ <link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" />
19
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment-with-locales.min.js" integrity="sha256-AdQN98MVZs44Eq2yTwtoKufhnU+uZ7v2kXnD5vqzZVo=" crossorigin="anonymous"></script>
20
+ <link rel="shortcut icon" type="image/png" href="https://nixos.org/favicon.png" />
21
+ <script type="text/javascript" src="/status.js"></script>
22
+ <link rel="stylesheet" href="/status.css" />
23
+
24
+ </head>
25
+
26
+ <body>
27
+
28
+ <div class="navbar navbar-fixed-top">
29
+ <div class="navbar-inner">
30
+ <div class="container">
31
+ <button type="button" class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
32
+ <span class="icon-bar"></span>
33
+ <span class="icon-bar"></span>
34
+ <span class="icon-bar"></span>
35
+ </button>
36
+ <li class="dropdown brand">
37
+ <a class="dropdown-toggle" href="#" data-toggle="dropdown">
38
+ <img src="https://nixos.org/logo/nix-wiki.png" alt="NixOS" class="logo" /> NixOS Status <b class="caret"></b>
39
+ <ul class="dropdown-menu">
40
+ <li><a href="https://nixos.org">NixOS.org</a></li>
41
+ </ul>
42
+ </a>
43
+ </li>
44
+
45
+ <div class="nav-collapse collapse">
46
+ <ul class="nav pull-left">
47
+ <li><a href="/grafana">Grafana</a></li>
48
+ <li><a href="/prometheus">Prometheus</a></li>
49
+ </ul>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="container main">
56
+ <div class="nixos-org-index">
57
+ <h1>NixOS</h1>
58
+ </div>
59
+
60
+ <div class="hero12">
61
+ <h2>Nix Channel Status</h2>
62
+ <p>See <a href="http://howoldis.herokuapp.com/">HowOldIs</a> and the <a href="https://nixos.wiki/wiki/Nix_channels">NixOS Wiki</a> for information on how channel updates progress.</p>
63
+ </p>
64
+ </p>
65
+ </div>
66
+
67
+ <div class="hero12">
68
+ <table class="table table-striped">
69
+ <thead>
70
+ <tr>
71
+ <th>Channel</th>
72
+ <th>Last updated</th>
73
+ <th>Commit</th>
74
+ <th>Hydra job for tests</th>
75
+ <th>Channel Status</th>
76
+ </tr>
77
+ </thead>
78
+ <tbody id="channel-status"></tbody>
79
+ </table>
80
+ </div>
81
+ </div>
82
+ </body>
83
+
84
+</html>
delft/eris/status-page/status.css
new
+29
@@ -0,0 +1,29 @@
1
+
2
+.stale {
3
+ opacity: 60%;
4
+}
5
+
6
+.channel {
7
+ font-family: monospace;
8
+ max-width: 20em;
9
+}
10
+
11
+.agetd {
12
+ max-width: 15em;
13
+}
14
+
15
+.github {
16
+ max-width: 10em;
17
+ overflow: hidden;
18
+ text-overflow: ellipsis;
19
+ font-family: monospace;
20
+}
21
+
22
+.hydra {
23
+ max-width: 20em;
24
+}
25
+
26
+#channel-status {
27
+ max-width: 100%;
28
+}
29
+
delft/eris/status-page/status.js
new
+187
@@ -0,0 +1,187 @@
1
+function aggregateByChannel(result) {
2
+ return result.reduce((acc, {
3
+ channel,
4
+ value
5
+ }) => ({
6
+ ...acc,
7
+ [channel]: value
8
+ }), {});
9
+}
10
+
11
+async function fetchData(queryType, queryArgs = {}) {
12
+ const params = new URLSearchParams();
13
+ for (const [key, value] of Object.entries(queryArgs)) {
14
+ params.set(key, String(value));
15
+ }
16
+
17
+ const response = await fetch(`https://status.nixos.org/prometheus/api/v1/${queryType}?${params}`);
18
+ const {
19
+ data
20
+ } = await response.json();
21
+
22
+ return data.result;
23
+}
24
+
25
+const revisionData = fetchData('query', {
26
+ query: 'channel_revision'
27
+ })
28
+ .then(records => (
29
+ records.map(({
30
+ metric
31
+ }) => ({
32
+ channel: metric.channel,
33
+ value: {
34
+ revision: metric.revision,
35
+ github_url: `https://github.com/NixOS/nixpkgs/commit/${metric.revision}`,
36
+ },
37
+ }))
38
+ ))
39
+ .then(aggregateByChannel);
40
+
41
+
42
+const updateTimeData = fetchData('query', {
43
+ query: 'channel_update_time'
44
+ })
45
+ .then(records => (
46
+ records.map(({
47
+ metric,
48
+ value
49
+ }) => ({
50
+ channel: metric.channel,
51
+ value: {
52
+ update_time: value[1],
53
+ },
54
+ }))
55
+ ))
56
+ .then(aggregateByChannel);
57
+
58
+var earliestStart = moment.utc("2019-12-30T01:00:00Z");
59
+var idealStart = moment.utc().subtract(30, "days");
60
+var start;
61
+if (idealStart > earliestStart) {
62
+ start = idealStart;
63
+} else {
64
+ start = earliestStart;
65
+}
66
+var end = moment.utc().format();
67
+const jobsetData = fetchData('query_range', {
68
+ query: 'hydra_job_failed',
69
+ start: start.format(),
70
+ end,
71
+ step: '1h'
72
+ })
73
+ .then(records => (
74
+ records.map(({
75
+ metric,
76
+ values
77
+ }) => {
78
+ const project = metric.project;
79
+ const jobset = metric.jobset;
80
+ const job = metric.exported_job;
81
+
82
+ return {
83
+ channel: metric.channel,
84
+ value: {
85
+ current: metric.current == 1,
86
+ project,
87
+ jobset,
88
+ job,
89
+ job_history: values.map(state => state[1] == 0),
90
+ hydra_url: `https://hydra.nixos.org/job/${project}/${jobset}/${job}`,
91
+ },
92
+ };
93
+ })
94
+ ))
95
+ .then(aggregateByChannel);
96
+
97
+function split_channel(channel) {
98
+ var parts = channel.split("-");
99
+ return {
100
+ "time": parts[1],
101
+ "collection": parts[0],
102
+ "qualifier": parts[2] || "",
103
+ }
104
+}
105
+
106
+function normalize_channel(channel) {
107
+ parts = split_channel(channel);
108
+ return [parts['time'], parts['collection'], parts['qualifier']].join("-");
109
+}
110
+
111
+function cmp_channels(left, right) {
112
+ return normalize_channel(left) < normalize_channel(right)
113
+}
114
+
115
+Promise.all([revisionData, updateTimeData, jobsetData])
116
+ .then(([revisions, update_times, jobsets]) => {
117
+ var combined = [];
118
+
119
+ for (let [channel, jobset] of Object.entries(jobsets)) {
120
+ // Ensure each jobset here is in each other dataset, guaranteeing we have
121
+ // complete data.
122
+ jobset['channel'] = channel;
123
+ if (revisions[channel] != undefined) {
124
+ jobset['revision'] = revisions[channel]['revision'];
125
+ jobset['github_url'] = revisions[channel]['github_url'];
126
+ } else {
127
+ continue
128
+ }
129
+ if (update_times[channel] != undefined) {
130
+ var m = moment.unix(update_times[channel]['update_time']);
131
+ jobset['update_time_relative'] = m.fromNow()
132
+ jobset['update_time_local'] = m.format()
133
+ if (m > moment().subtract(3, 'days')) {
134
+ jobset['update_age'] = "success";
135
+ } else if (m > moment().subtract(10, 'days')) {
136
+ jobset['update_age'] = "warning";
137
+ } else {
138
+ jobset['update_age'] = "important";
139
+ }
140
+ } else {
141
+ continue
142
+ }
143
+
144
+ combined.push(jobset);
145
+ }
146
+
147
+ combined.sort((left, right) => cmp_channels(left['channel'], right['channel']));
148
+
149
+ return combined;
150
+ })
151
+ .then(data => {
152
+ return data.map(record => {
153
+ var row = document.createElement('tr');
154
+ row.innerHTML = '<td class="channel" /><td><span class="age label"></span></td><td class="github"><a class="revision" /></td><td class="hydra" title="The state of the job over time"><a class="hydra-link" /></td><td class="status"></td>';
155
+ if (record['current']) {
156
+ row.classList.add("current")
157
+ } else {
158
+ row.classList.add("stale")
159
+ row.getElementsByClassName("status")[0].innerHTML += '<span class="label label-important">End of life</span>';
160
+ }
161
+ row.getElementsByClassName("channel")[0].innerText = record['channel'];
162
+
163
+ row.getElementsByClassName("age")[0].innerText = record['update_time_relative'];
164
+ row.getElementsByClassName("age")[0].title = record['update_time_local'];
165
+ row.getElementsByClassName("age")[0].classList.add("label-" + record['update_age']);
166
+ row.getElementsByClassName("revision")[0].innerText = record['revision'];
167
+ row.getElementsByClassName("revision")[0].href = record['github_url'];
168
+
169
+ row.getElementsByClassName("hydra-link")[0].href = record['hydra_url'];
170
+ row.getElementsByClassName("hydra-link")[0].innerText = [record['project'], record['jobset'], record['job']].join('/');
171
+ row.getElementsByClassName("hydra")[0].style.backgroundImage = "linear-gradient(to right, " +
172
+ (record['job_history'].map(val => val ? "#b5ffb5" : "#ff9e9e")).join(", ") +
173
+ ")";
174
+
175
+ if (record['job_history'][record['job_history'].length - 1] == 0) {
176
+ row.getElementsByClassName("status")[0].innerHTML += '<span class="label label-important">Failing</span>';
177
+
178
+ }
179
+ return row;
180
+ })
181
+ })
182
+ .then(rows => {
183
+ var tbody = document.getElementById("channel-status");
184
+ rows.forEach(row => {
185
+ tbody.appendChild(row);
186
+ });
187
+ });