Add site status detection feature

This commit is contained in:
Dale Davies
2022-07-25 12:55:53 +01:00
parent 775ff681b1
commit 6a2b38dd55
12 changed files with 258 additions and 23 deletions

View File

@@ -1,3 +1,9 @@
// Variables for status colours
$online-color: #55e22a;
$offline-color: #ec2c2c;
$error-color: #ddd900;
$unknown-color: #ccc;
.sites {
padding: 0;
margin: 0;
@@ -32,6 +38,7 @@
background-color: #fff;
width: 80px;
height: 80px;
position: relative;
border-radius: 6px;
border: .2em solid #fff;
box-shadow: 0 1px 5px rgba(0,0,0,.3);
@@ -60,6 +67,66 @@
}
}
.status {
.sites:not(.alternate) {
.icon {
overflow: hidden;
border: none;
height: 82px;
&::after {
content: '';
display: inline-block;
width: 100%;
height: 4px;
position: absolute;
left: 0;
bottom: 0;
background-color: $unknown-color;
}
}
.online .icon::after {
background-color: $online-color;
}
.offline .icon::after {
background-color: $offline-color;
}
.error .icon::after {
background-color: $error-color;
}
}
.sites.alternate {
li {
a {
padding-left: 11px;
position: relative;
&::before {
content: '';
display: inline-block;
height: 100%;
width: 4px;
position: absolute;
left: 0;
top: 0;
background-color: $unknown-color;
}
}
&.online a::before {
background-color: $online-color;
}
&.offline a::before {
background-color: $offline-color;
}
&.error a::before {
background-color: $error-color;
}
}
}
}
.sites.alternate {
width: 100%;
margin-top: 20px;

View File

@@ -50,9 +50,9 @@ export default class Main {
if (this.showsearchbuttonelm) {
this.searchclosebuttonelm = this.showsearchbuttonelm.querySelector('.close');
this.fuse = new Fuse(JSON.parse(JUMP.search), {
this.fuse = new Fuse(JSON.parse(JUMP.sites), {
threshold: 0.3,
keys: ['name', 'tags']
keys: ['name', 'tags', 'url']
});
}
}
@@ -79,6 +79,24 @@ export default class Main {
});
}
// If enables then check the status API and update the frontend according to result.
if (JUMP.checkstatus) {
fetch(JUMP.wwwurl + '/api/status/' + JUMP.token + '/')
.then(response => response.json())
.then(statuses => {
for (const [id, status] of Object.entries(statuses)) {
const siteelm = document.querySelector('#'+id);
if (siteelm) {
siteelm.classList.add(status);
if (status !== 'online') {
const sitelinkelm = siteelm.querySelector('a');
sitelinkelm.title = '(Status: ' + status + ') ' + sitelinkelm.title;
}
}
}
});
}
// Start listening for events so we can do stuff when needed.
this.add_event_listeners();
// If there is no OWM API key provided then just update the greeting

View File

@@ -0,0 +1,29 @@
<?php
/**
* ██ ██ ██ ███ ███ ██████
* ██ ██ ██ ████ ████ ██ ██
* ██ ██ ██ ██ ████ ██ ██████
* ██ ██ ██ ██ ██ ██ ██ ██
* █████ ██████ ██ ██ ██
*
* @author Dale Davies <dale@daledavies.co.uk>
* @copyright Copyright (c) 2022, Dale Davies
* @license MIT
*/
namespace Jump\API;
class Status extends AbstractAPI {
public function get_output(): string {
$this->validate_token();
$statusarray = [];
$sites = (new \Jump\Sites($this->config, $this->cache))->get_sites();
foreach ($sites as $site) {
$status = $site->get_status();
$statusarray[$site->id] = $status;
}
return json_encode($statusarray);
}
}

View File

@@ -24,7 +24,7 @@ use Nette\Caching;
*/
class Cache {
private Caching\Storages\FileStorage $storage;
private Caching\Storages\FileStorage|Caching\Storages\DevNullStorage $storage;
/**
* The definition of various caches used throughout the application.
@@ -33,10 +33,12 @@ class Cache {
*/
private array $caches;
/**
* Creates file storage for cache and initialises cache objects for each
* name/type specified in $caches definition.
*/
/**
* Creates file storage for cache and initialises cache objects for each
* name/type specified in $caches definition.
*
* @param Config $config
*/
public function __construct(private Config $config) {
// Define the various caches used throughout the app.
$this->caches = [
@@ -50,6 +52,11 @@ class Cache {
'expirationtype' => Caching\Cache::FILES,
'expirationparams' => $config->get('sitesfile')
],
'sites/status' => [
'cache' => null,
'expirationtype' => Caching\Cache::EXPIRE,
'expirationparams' => '5 minutes'
],
'tags' => [
'cache' => null,
'expirationtype' => Caching\Cache::FILES,
@@ -85,7 +92,12 @@ class Cache {
],
];
// Inititalise file storage for cache using cachedir path from config.
$this->storage = new Caching\Storages\FileStorage($this->config->get('cachedir').'/application');
// If cachebypass has been set in config.php then use DevNullStorage instead.
if ($this->config->parse_bool($this->config->get('cachebypass'))) {
$this->storage = new Caching\Storages\DevNullStorage();
} else {
$this->storage = new Caching\Storages\FileStorage($this->config->get('cachedir').'/application');
}
}
/**
@@ -116,10 +128,6 @@ class Cache {
* @return mixed The result of callback function retreieved from cache.
*/
public function load(string $cachename, ?string $key = 'default', callable $callback = null): mixed {
// If cachebypass has been set in config.php then just execute the callback.
if ($this->config->parse_bool($this->config->get('cachebypass')) && $callback !== null) {
return $callback();
}
$this->init_cache($cachename, $key);
// Retrieve the initialised cache object from $caches.
if ($callback === null) {
@@ -143,10 +151,9 @@ class Cache {
* @param mixed $data
* @return void
*/
public function save(string $cachename, ?string $key = 'default', mixed $data) {
public function save(string $cachename, ?string $key = 'default', mixed $data): mixed {
$this->init_cache($cachename, $key);
$dependencies = [$this->caches[$cachename]['expirationtype'] => $this->caches[$cachename]['expirationparams']];
// Retrieve the initialised cache object from $caches.
return $this->caches[$cachename]['cache'][$key]->save($cachename.'/'.$key, $data, $dependencies);
}

View File

@@ -37,6 +37,9 @@ class Main {
$this->router->addRoute('/api/icon?siteurl=<siteurl>', [
'class' => 'Jump\API\Icon'
]);
$this->router->addRoute('/api/status[/<token>]', [
'class' => 'Jump\API\Status'
]);
$this->router->addRoute('/api/unsplash[/<token>]', [
'class' => 'Jump\API\Unsplash'
]);

View File

@@ -34,12 +34,15 @@ class HomePage extends AbstractPage {
'unsplash' => !!$this->config->get('unsplashapikey', false),
'unsplashcolor' => $unsplashdata?->color,
'wwwurl' => $this->config->get_wwwurl(),
'checkstatus' => $this->config->parse_bool($this->config->get('checkstatus', false)),
];
if ($this->config->parse_bool($this->config->get('showsearch', false))) {
$templatecontext = array_merge($templatecontext, [
'searchengines' => json_encode((new \Jump\SearchEngines($this->config, $this->cache))->get_search_engines()),
'searchjson' => json_encode((new \Jump\Sites($this->config, $this->cache))->get_sites_for_search()),
]);
$showsearch = $this->config->parse_bool($this->config->get('showsearch', false));
$showstatus = $this->config->parse_bool($this->config->get('showsearch', false));
if ($showsearch || $showstatus) {
$templatecontext['sitesjson'] = json_encode((new \Jump\Sites($this->config, $this->cache))->get_sites_for_frontend());
if ($showsearch) {
$templatecontext['searchengines'] = json_encode((new \Jump\SearchEngines($this->config, $this->cache))->get_search_engines());
}
}
return $template->render($templatecontext);
}

View File

@@ -39,6 +39,7 @@ class Site {
if (!isset($sitearray['name'], $sitearray['url'])) {
throw new \Exception('The array passed to Site() must contain the keys "name" and "url"!');
}
$this->id = preg_replace("/[^A-Za-z0-9 ]/", '', $sitearray['url']);
$this->name = $sitearray['name'];
$this->url = $sitearray['url'];
$this->nofollow = isset($sitearray['nofollow']) ? $sitearray['nofollow'] : (isset($this->defaults['nofollow']) ? $this->defaults['nofollow'] : false);
@@ -91,4 +92,13 @@ class Site {
return 'data:'.$imagedata->mimetype.';base64,'.base64_encode($imagedata->data);
}
/**
* Get the online status of this site.
*
* @return string The site status.
*/
public function get_status(): string {
$cache = new Cache($this->config);
return (new Status($cache, $this))->get_status();
}
}

View File

@@ -142,6 +142,21 @@ class Sites {
return $this->loadedsites[$found];
}
/**
* Given a Site ID, does that site exist in our list of sites?
*
* @param string $id The Site ID to search for.
* @return Site A matching Site object if found.
* @throws Exception If a site with given Site ID does not exist.
*/
public function get_site_by_id(string $id): Site {
$found = array_search($id, array_column($this->get_sites(), 'id'));
if ($found === false) {
throw new Exception('The site could not be found ('.$id.')');
}
return $this->loadedsites[$found];
}
/**
* Returns an array of Site objects with a given tag.
*
@@ -162,14 +177,22 @@ class Sites {
return $found;
}
public function get_sites_for_search(): array {
/**
* Get a list of cached sites from for use in the front end via JS. Some extra details
* added to each site in the list may not be already saved in the main sites cache.
*
* @return array Array of stdClass objects containing required site details.
*/
public function get_sites_for_frontend(): array {
$searchlist = [];
foreach ($this->loadedsites as $loadedsite) {
$site = new \stdClass();
$site->id = $loadedsite->id;
$site->name = $loadedsite->name;
$site->url = $loadedsite->url;
$site->tags = $loadedsite->tags;
$site->iconurl = '/api/icon?siteurl='.urlencode($loadedsite->url);
$site->status = $this->cache->load(cachename: 'sites/status', key: $site->url) ?? null;
$searchlist[] = $site;
}
return $searchlist;

View File

@@ -0,0 +1,72 @@
<?php
/**
* ██ ██ ██ ███ ███ ██████
* ██ ██ ██ ████ ████ ██ ██
* ██ ██ ██ ██ ████ ██ ██████
* ██ ██ ██ ██ ██ ██ ██ ██
* █████ ██████ ██ ██ ██
*
* @author Dale Davies <dale@daledavies.co.uk>
* @copyright Copyright (c) 2022, Dale Davies
* @license MIT
*/
namespace Jump;
class Status {
private const STATUS_UNKNOWN = 'unknown';
private const STATUS_ONLINE = 'online';
private const STATUS_OFFLINE = 'offline';
private const STATUS_ERROR = 'error';
private $connectionTimeout = 10;
private $requestTimeout = 30;
/**
* Allows for checking if a site is online/offline or returns an error code.
*
* @param Cache $cache
* @param Site $site
*/
public function __construct(private Cache $cache, public Site $site) {
$this->status = $this->cache->load(cachename: 'sites/status', key: $this->site->id);
}
/**
* Get the site's status.
*
* @return string The site status.
*/
public function get_status(): string {
// If we haven't got a status already cachhed then try connecting to the site
// and save the status to the cache.
if (!$this->status) {
// Create a new client with client config.
$client = new \GuzzleHttp\Client([
'connect_timeout' => $this->connectionTimeout,
'timeout' => $this->requestTimeout,
'allow_redirects' => true
]);
// Try to connect to site and determine status.
try {
if($client->request('GET', $this->site->url)) {
$status = self::STATUS_ONLINE;
}
} catch (\GuzzleHttp\Exception\ConnectException) {
// Catch instances where we cant connect.
$status = self::STATUS_OFFLINE;
} catch (\GuzzleHttp\Exception\BadResponseException) {
// Catch 4xx and 5xx errors.
$status = self::STATUS_ERROR;
} catch (\Exception) {
// If anything went wrong or we had some other status code.
$status = self::STATUS_UNKNOWN;
}
// Save the status to the cache.
$this->status = $this->cache->save(cachename: 'sites/status', key: $this->site->id, data: $status);
}
// Finally return the status.
return $this->status;
}
}

View File

@@ -57,4 +57,6 @@ return [
'latlong' => getenv('LATLONG') ?: '',
// Temperature unit: True = metric / False = imperial.
'metrictemp' => getenv('METRICTEMP') ?: true,
'checkstatus' => getenv('CHECKSTATUS') ?: true,
];

View File

@@ -16,7 +16,8 @@
metrictemp: '{{metrictemp}}',
ampmclock: '{{ampmclock}}',
token: '{{csrftoken}}',
search: '{{{searchjson}}}',
sites: '{{{sitesjson}}}',
checkstatus: '{{checkstatus}}',
searchengines: '{{{searchengines}}}',
unsplash: '{{{unsplash}}}',
unsplashcolor: '{{unsplashcolor}}',
@@ -25,7 +26,7 @@
</script>
</head>
<body>
<div class="content fixed hidden">
<div class="content fixed hidden {{# checkstatus}}status{{/ checkstatus}}">
<div class="greeting">
{{# greeting}}<span class="tagname"><span>#</span>{{greeting}}</span>{{/ greeting}}
{{^ greeting}}Good <span class="chosen"></span>{{/ greeting}}

View File

@@ -1,6 +1,6 @@
{{# hassites}}
<ul class="sites {{# altlayout}}alternate{{/ altlayout}}">
{{# sites}}<li><a {{# newtab}}target="_blank"{{/ newtab}} rel="{{# nofollow}}nofollow {{/ nofollow}}{{# newtab}}noopener{{/ newtab}}" title="{{description}}" href="{{url}}">
{{# sites}}<li id="{{id}}"><a {{# newtab}}target="_blank"{{/ newtab}} rel="{{# nofollow}}nofollow {{/ nofollow}}{{# newtab}}noopener{{/ newtab}}" title="{{description}}" href="{{url}}">
<span class="icon">
<img src="{{{wwwurl}}}/api/icon?siteurl={{#urlencode}}{{url}}{{/urlencode}}">
</span>