diff --git a/jumpapp/assets/css/src/_sites.scss b/jumpapp/assets/css/src/_sites.scss index 7599e01..3842033 100644 --- a/jumpapp/assets/css/src/_sites.scss +++ b/jumpapp/assets/css/src/_sites.scss @@ -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; diff --git a/jumpapp/assets/js/src/classes/Main.js b/jumpapp/assets/js/src/classes/Main.js index 442c76e..fff3d83 100644 --- a/jumpapp/assets/js/src/classes/Main.js +++ b/jumpapp/assets/js/src/classes/Main.js @@ -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 diff --git a/jumpapp/classes/API/Status.php b/jumpapp/classes/API/Status.php new file mode 100644 index 0000000..ababb45 --- /dev/null +++ b/jumpapp/classes/API/Status.php @@ -0,0 +1,29 @@ + + * @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); + } + +} diff --git a/jumpapp/classes/Cache.php b/jumpapp/classes/Cache.php index 1cc8431..54aaec0 100644 --- a/jumpapp/classes/Cache.php +++ b/jumpapp/classes/Cache.php @@ -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); } diff --git a/jumpapp/classes/Main.php b/jumpapp/classes/Main.php index c467a5f..50a2c6b 100644 --- a/jumpapp/classes/Main.php +++ b/jumpapp/classes/Main.php @@ -37,6 +37,9 @@ class Main { $this->router->addRoute('/api/icon?siteurl=', [ 'class' => 'Jump\API\Icon' ]); + $this->router->addRoute('/api/status[/]', [ + 'class' => 'Jump\API\Status' + ]); $this->router->addRoute('/api/unsplash[/]', [ 'class' => 'Jump\API\Unsplash' ]); diff --git a/jumpapp/classes/Pages/HomePage.php b/jumpapp/classes/Pages/HomePage.php index 0dbb9d0..99ecdbd 100644 --- a/jumpapp/classes/Pages/HomePage.php +++ b/jumpapp/classes/Pages/HomePage.php @@ -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); } diff --git a/jumpapp/classes/Site.php b/jumpapp/classes/Site.php index bdf069e..d60f146 100644 --- a/jumpapp/classes/Site.php +++ b/jumpapp/classes/Site.php @@ -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(); + } } diff --git a/jumpapp/classes/Sites.php b/jumpapp/classes/Sites.php index 3408373..2f3a546 100644 --- a/jumpapp/classes/Sites.php +++ b/jumpapp/classes/Sites.php @@ -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; diff --git a/jumpapp/classes/Status.php b/jumpapp/classes/Status.php new file mode 100644 index 0000000..d88edc4 --- /dev/null +++ b/jumpapp/classes/Status.php @@ -0,0 +1,72 @@ + + * @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; + } +} diff --git a/jumpapp/config.php b/jumpapp/config.php index 7c5a38a..6d3c25e 100644 --- a/jumpapp/config.php +++ b/jumpapp/config.php @@ -57,4 +57,6 @@ return [ 'latlong' => getenv('LATLONG') ?: '', // Temperature unit: True = metric / False = imperial. 'metrictemp' => getenv('METRICTEMP') ?: true, + + 'checkstatus' => getenv('CHECKSTATUS') ?: true, ]; diff --git a/jumpapp/templates/header.mustache b/jumpapp/templates/header.mustache index 12f8bf9..f9787a8 100644 --- a/jumpapp/templates/header.mustache +++ b/jumpapp/templates/header.mustache @@ -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 @@ -