diff --git a/cron.sh b/cron.sh
new file mode 100755
index 0000000..9bcf587
--- /dev/null
+++ b/cron.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+set -eu
+
+cd $(dirname $0)
+
+./env/bin/python devicecheck.py
diff --git a/devicecheck.py b/devicecheck.py
index 70a16dc..c8ed449 100644
--- a/devicecheck.py
+++ b/devicecheck.py
@@ -1,6 +1,95 @@
 #!/usr/bin/env python3
 
+import subprocess
+
+from datetime import datetime
+
 import mysql.connector as sql
 from mysql.connector import IntegrityError
 import requests
 
+
+class Checker:
+    def __init__(self, config):
+        self.apisession = requests.Session()
+        self.apisession.auth = (config['deviceapi']['user'],
+                                config['deviceapi']['pass'])
+        self.apisession.headers = {'Accept':       'application/json',
+                                   'Content-Type': 'application/json'}
+
+        self.apiurl = config['deviceapi']['url']
+        if self.apiurl.endswith('/'):
+            self.apiurl = self.apiurl[:-1]
+
+        self.dbparameters = {
+            'host': config['database']['host'],
+            'port': config['database']['port'],
+            'database': config['database']['database'],
+            'user': config['database']['user'],
+            'password': config['database']['pass'],
+            }
+
+    def _do_request(self, path):
+        response = self.apisession.get(self.apiurl + path)
+        response.raise_for_status()
+        return response.json()
+
+    def check_all(self):
+        hostcache = set()
+        for item in self._do_request('/config/devices'):
+            if 'host' in item:
+                hostcache.add(item['host'])
+
+        for host in hostcache:
+            result = self.ping(host)
+            self.store(host, result)
+
+            if result.returncode == 0:
+                print(f'{host} OK')
+            else:
+                print(f'{host}: {result.returncode} - {result.stderr}')
+
+        self._prune_db()
+
+    def ping(self, host):
+        # Ping settings:
+        # - send 3 packets
+        # - wait 0.5 seconds between packets
+        # - timeout each packet after 1 second
+        # - timeout entire operation after 5 seconds
+        command = ['ping', '-q',
+                   '-c', '3',
+                   '-i', '0.5',
+                   '-W', '1',
+                   '-w', '5',
+                   host]
+        return subprocess.run(command, capture_output=True, text=True)
+
+    def _prune_db(self):
+        stmt = 'delete from `results`'
+        stmt += 'where `timestamp` < unix_timestamp(date_sub(now(), interval 30 day))'
+        with sql.connect(**self.dbparameters) as db:
+            with db.cursor() as cursor:
+                cursor.execute(stmt)
+            db.commit()
+
+    def store(self, host, result):
+        now = datetime.now().timestamp()
+        stmt = 'insert into `results`'
+        stmt += '(`host`, `timestamp`, `returncode`, `detail`)'
+        stmt += 'values(%s, %s, %s, %s)'
+        with sql.connect(**self.dbparameters) as db:
+            with db.cursor() as cursor:
+                cursor.execute(stmt, (host,
+                                      now,
+                                      result.returncode,
+                                      result.stderr))
+            db.commit()
+
+
+if __name__ == '__main__':
+    from configparser import ConfigParser
+    conf = ConfigParser()
+    conf.read('config.ini')
+    checker = Checker(conf)
+    checker.check_all()
diff --git a/public/get.php b/public/get.php
new file mode 100644
index 0000000..db520b2
--- /dev/null
+++ b/public/get.php
@@ -0,0 +1,25 @@
+<?php
+header('content-type: application/json');
+
+$conf = parse_ini_file('../config.ini', true);
+$dbconf = $conf['database'];
+$db = new mysqli($dbconf['host'],
+                 $dbconf['user'],
+                 $dbconf['pass'],
+                 $dbconf['database'],
+                 $dbconf['port']);
+
+$result = $db->query('select o.* from results as o
+                          inner join (select host, max(timestamp) as latest
+                            from results group by host)
+                          as i on o.host= i.host
+                      where o.timestamp = i.latest');
+
+$out = [];
+foreach($result as $row) {
+    $out[$row['host']] = ['time' => $row['timestamp'],
+                              'status' => $row['returncode'],
+                              'detail' => $row['detail']];
+}
+print(json_encode($out));
+?>
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..9ea5098
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,26 @@
+<!doctype html>
+<html lang="se">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="initial-scale=1.0">
+    <meta http-equiv="X-UA-Compatible" content="ie=edge">
+    <meta http-equiv="refresh" content="60" />
+    <title>Status salsteknik</title>
+    <script type="text/javascript" src="./script.js" defer></script>
+    <link rel="stylesheet" href="./style.css" />
+  </head>
+  <body>
+    <h2>Devices with errors</h2>
+    <errors></errors>
+    <h2>Devices working as expected</h2>
+    <oks></oks>
+    <template id="itembox">
+      <div class="itembox">
+        <name></name>
+        <checktime></checktime>
+        <status></status>
+        <detail></detail>
+      </div>
+    </template>
+  </body>
+</html>
diff --git a/public/script.js b/public/script.js
new file mode 100644
index 0000000..a7802f2
--- /dev/null
+++ b/public/script.js
@@ -0,0 +1,47 @@
+const template = document.getElementById('itembox');
+const oks = document.querySelector('oks');
+const errors = document.querySelector('errors');
+const now = Date.now();
+fetch('./get.php')
+    .then((response) => {
+        if (!response.ok) {
+            throw new Error(`HTTP error: ${response.status}`);
+        }
+        return response.json();
+    })
+    .then((data) => {
+        names = Object.keys(data);
+        names.sort();
+        for(var i = 0; i < names.length; ++i) {
+            const key = names[i];
+            const item = data[key];
+            const instance = template.content.cloneNode(true);
+            const name = instance.querySelector('name');
+            const checktime = instance.querySelector('checktime');
+            const status = instance.querySelector('status');
+            const detail = instance.querySelector('detail');
+
+            name.textContent = key.replace(/\.dsv\.local\.su\.se/, '');
+            checktime.textContent = formatTime(now/1000 - item.time);
+
+            if(item.status == '0') {
+                status.parentNode.removeChild(status);
+                detail.parentNode.removeChild(detail);
+                instance.firstElementChild.classList.add('good');
+                oks.appendChild(instance);
+            } else {
+                status.textContent = item.status;
+                detail.textContent = item.detail;
+                instance.firstElementChild.classList.add('bad');
+                errors.appendChild(instance);
+            }
+        }
+    });
+
+function formatTime(secondsAgo) {
+    if (secondsAgo < 60) {
+        return secondsAgo + " seconds ago";
+    } else {
+        return (secondsAgo/60).toFixed(0) + " minutes ago";
+    }
+}
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000..26efbcb
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,26 @@
+oks, errors {
+    display: grid;
+    gap: 20px;
+    place-items: stretch;
+    place-content: center;
+    grid-template-columns: repeat(auto-fill, 300px);
+    #grid-auto-rows: 75px;
+    grid-auto-flow: row;
+}
+
+.itembox {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    color: white;
+    font-weight: bold;
+    padding: 10px;
+}
+
+.good {
+    background-color: green;
+}
+
+.bad {
+    background-color: red;
+}