visual feedback during updates
This commit is contained in:
parent
0f0202a3aa
commit
516397ae44
9 changed files with 274 additions and 25 deletions
12
ctl
12
ctl
|
@ -54,7 +54,7 @@ hash -r 2>/dev/null
|
||||||
# allow more open files
|
# allow more open files
|
||||||
ulimit -S -n 2048
|
ulimit -S -n 2048
|
||||||
|
|
||||||
function remove_loginscript {
|
function remove_autostart {
|
||||||
if [ $SYSTEM == "Darwin" ]; then
|
if [ $SYSTEM == "Darwin" ]; then
|
||||||
launchd_name="com.openmedialibrary.loginscript"
|
launchd_name="com.openmedialibrary.loginscript"
|
||||||
launchd_plist="$HOME/Library/LaunchAgents/${launchd_name}.plist"
|
launchd_plist="$HOME/Library/LaunchAgents/${launchd_name}.plist"
|
||||||
|
@ -64,15 +64,15 @@ function remove_loginscript {
|
||||||
rm "$launchd_plist"
|
rm "$launchd_plist"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$1" == "start" ]; then
|
|
||||||
remove_loginscript
|
|
||||||
if [ $SYSTEM == "Linux" ]; then
|
if [ $SYSTEM == "Linux" ]; then
|
||||||
if [ -e "$HOME/.config/autostart/openmedialibrary.desktop" ]; then
|
if [ -e "$HOME/.config/autostart/openmedialibrary.desktop" ]; then
|
||||||
rm "$HOME/.config/autostart/openmedialibrary.desktop"
|
rm "$HOME/.config/autostart/openmedialibrary.desktop"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$1" == "start" ]; then
|
||||||
|
remove_autostart
|
||||||
cd "$BASE/$NAME"
|
cd "$BASE/$NAME"
|
||||||
if [ -e $PID ]; then
|
if [ -e $PID ]; then
|
||||||
if ps -p `cat "$PID"` > /dev/null; then
|
if ps -p `cat "$PID"` > /dev/null; then
|
||||||
|
@ -96,7 +96,7 @@ if [ "$1" == "debug" ]; then
|
||||||
exec python3 oml server debug $PID
|
exec python3 oml server debug $PID
|
||||||
fi
|
fi
|
||||||
if [ "$1" == "stop" ]; then
|
if [ "$1" == "stop" ]; then
|
||||||
remove_loginscript
|
remove_autostart
|
||||||
if [ -e $PID ]; then
|
if [ -e $PID ]; then
|
||||||
_PID=`cat $PID`
|
_PID=`cat $PID`
|
||||||
kill $_PID
|
kill $_PID
|
||||||
|
|
4
install
4
install
|
@ -114,10 +114,10 @@ class Install(Thread):
|
||||||
self.status['installing'] = 'setup'
|
self.status['installing'] = 'setup'
|
||||||
if sys.platform.startswith('linux'):
|
if sys.platform.startswith('linux'):
|
||||||
os.system('./ctl install_launcher')
|
os.system('./ctl install_launcher')
|
||||||
os.system('./ctl setup')
|
|
||||||
self.status['progress'] = 1
|
|
||||||
with open('config/release.json', 'w') as fd:
|
with open('config/release.json', 'w') as fd:
|
||||||
json.dump(release, fd, indent=2)
|
json.dump(release, fd, indent=2)
|
||||||
|
os.system('./ctl setup')
|
||||||
|
self.status['progress'] = 1
|
||||||
self.status['done'] = True
|
self.status['done'] = True
|
||||||
|
|
||||||
def download(self, url, filename):
|
def download(self, url, filename):
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# vi:si:et:sw=4:sts=4:ts=4
|
# vi:si:et:sw=4:sts=4:ts=4
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import signal
|
import signal
|
||||||
|
@ -23,6 +22,7 @@ import setup
|
||||||
import state
|
import state
|
||||||
import tasks
|
import tasks
|
||||||
import websocket
|
import websocket
|
||||||
|
import update
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -31,13 +31,15 @@ logger = logging.getLogger(__name__)
|
||||||
class MainHandler(OMLHandler):
|
class MainHandler(OMLHandler):
|
||||||
|
|
||||||
def get(self, path):
|
def get(self, path):
|
||||||
path = os.path.join(settings.static_path, 'html', 'oml.html')
|
|
||||||
with open(path) as fd:
|
|
||||||
content = fd.read()
|
|
||||||
version = settings.MINOR_VERSION.split('-')[0]
|
version = settings.MINOR_VERSION.split('-')[0]
|
||||||
if version == 'git':
|
if version == 'git':
|
||||||
version = int(time.mktime(time.gmtime()))
|
version = int(time.mktime(time.gmtime()))
|
||||||
content = content.replace('oml.js?1', 'oml.js?%s' % version)
|
path = os.path.join(settings.static_path, 'html', 'oml.html')
|
||||||
|
with open(path) as fd:
|
||||||
|
content = fd.read()
|
||||||
|
content = content.replace('.js?1', '.js?%s' % version)
|
||||||
|
if state.update:
|
||||||
|
content = content.replace('oml.js', 'oml.update.js')
|
||||||
self.set_header('Content-Type', 'text/html')
|
self.set_header('Content-Type', 'text/html')
|
||||||
self.set_header('Content-Length', str(len(content)))
|
self.set_header('Content-Length', str(len(content)))
|
||||||
self.write(content)
|
self.write(content)
|
||||||
|
@ -133,11 +135,11 @@ def run():
|
||||||
with open(PID, 'w') as pid:
|
with open(PID, 'w') as pid:
|
||||||
pid.write('%s' % os.getpid())
|
pid.write('%s' % os.getpid())
|
||||||
|
|
||||||
|
state.update = update.update_available()
|
||||||
state.PID = PID
|
state.PID = PID
|
||||||
state.http_server = http_server
|
state.http_server = http_server
|
||||||
state.main = IOLoop.instance()
|
state.main = IOLoop.instance()
|
||||||
state.cache = Cache(ttl=60)
|
state.cache = Cache(ttl=60)
|
||||||
state.tasks = tasks.Tasks()
|
|
||||||
|
|
||||||
def start_node():
|
def start_node():
|
||||||
import downloads
|
import downloads
|
||||||
|
@ -156,7 +158,13 @@ def run():
|
||||||
else:
|
else:
|
||||||
nodes.publish_node()
|
nodes.publish_node()
|
||||||
state.main.add_callback(publish)
|
state.main.add_callback(publish)
|
||||||
state.main.add_callback(start_node)
|
|
||||||
|
if not state.update:
|
||||||
|
state.tasks = tasks.Tasks()
|
||||||
|
state.main.add_callback(start_node)
|
||||||
|
else:
|
||||||
|
state.tasks = update.Update()
|
||||||
|
|
||||||
if ':' in settings.server['address']:
|
if ':' in settings.server['address']:
|
||||||
host = '[%s]' % settings.server['address']
|
host = '[%s]' % settings.server['address']
|
||||||
elif not settings.server['address']:
|
elif not settings.server['address']:
|
||||||
|
|
|
@ -78,7 +78,8 @@ else:
|
||||||
NODE_PROTOCOL="0.4"
|
NODE_PROTOCOL="0.4"
|
||||||
VERSION="%s.%s" % (NODE_PROTOCOL, MINOR_VERSION)
|
VERSION="%s.%s" % (NODE_PROTOCOL, MINOR_VERSION)
|
||||||
|
|
||||||
|
|
||||||
USER_AGENT = 'OpenMediaLibrary/%s' % VERSION
|
USER_AGENT = 'OpenMediaLibrary/%s' % VERSION
|
||||||
|
|
||||||
DEBUG_HTTP = server.get('debug_http', False)
|
DEBUG_HTTP = server.get('debug_http', False)
|
||||||
|
|
||||||
|
DB_VERSION = 0
|
||||||
|
|
|
@ -2,11 +2,13 @@ bandwidth = None
|
||||||
host = None
|
host = None
|
||||||
main = None
|
main = None
|
||||||
nodes = False
|
nodes = False
|
||||||
|
node = False
|
||||||
online = False
|
online = False
|
||||||
tasks = False
|
tasks = False
|
||||||
scraping = False
|
scraping = False
|
||||||
downloads = False
|
downloads = False
|
||||||
tor = False
|
tor = False
|
||||||
|
update = False
|
||||||
websockets = []
|
websockets = []
|
||||||
|
|
||||||
activity = {}
|
activity = {}
|
||||||
|
|
|
@ -6,7 +6,7 @@ from queue import Queue
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
from websocket import trigger_event
|
from websocket import trigger_event
|
||||||
|
import state
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -34,8 +34,6 @@ class Tasks(Thread):
|
||||||
item.scan.run_import(data)
|
item.scan.run_import(data)
|
||||||
elif action == 'scan':
|
elif action == 'scan':
|
||||||
item.scan.run_scan()
|
item.scan.run_scan()
|
||||||
elif action == 'update':
|
|
||||||
trigger_event('error', {'error': 'not implemented'})
|
|
||||||
else:
|
else:
|
||||||
trigger_event('error', {'error': 'unknown action'})
|
trigger_event('error', {'error': 'unknown action'})
|
||||||
except:
|
except:
|
||||||
|
|
|
@ -238,7 +238,7 @@ def install_tor():
|
||||||
if get_tor():
|
if get_tor():
|
||||||
print('found existing tor installation')
|
print('found existing tor installation')
|
||||||
url = torbrowser_url()
|
url = torbrowser_url()
|
||||||
target = os.path.join(settings.base_dir, 'tor')
|
target = os.path.normpath(os.path.join(settings.base_dir, '..', 'tor'))
|
||||||
if url:
|
if url:
|
||||||
print('downloading and installing tor')
|
print('downloading and installing tor')
|
||||||
if sys.platform.startswith('linux'):
|
if sys.platform.startswith('linux'):
|
||||||
|
|
|
@ -6,10 +6,12 @@ from contextlib import closing
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tarfile
|
import tarfile
|
||||||
|
from threading import Thread
|
||||||
import urllib.request, urllib.error, urllib.parse
|
import urllib.request, urllib.error, urllib.parse
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
import ed25519
|
import ed25519
|
||||||
import ox
|
import ox
|
||||||
|
@ -145,7 +147,6 @@ def install():
|
||||||
shutil.copy(os.path.join(settings.updates_path, 'release.json'), os.path.join(settings.config_path, 'release.json'))
|
shutil.copy(os.path.join(settings.updates_path, 'release.json'), os.path.join(settings.config_path, 'release.json'))
|
||||||
for cmd in [
|
for cmd in [
|
||||||
['./ctl', 'stop'],
|
['./ctl', 'stop'],
|
||||||
['./ctl', 'setup'],
|
|
||||||
['./ctl', 'postupdate', '-o', old_version, '-n', new_version]
|
['./ctl', 'postupdate', '-o', old_version, '-n', new_version]
|
||||||
]:
|
]:
|
||||||
subprocess.call(cmd)
|
subprocess.call(cmd)
|
||||||
|
@ -153,6 +154,26 @@ def install():
|
||||||
return True
|
return True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def update_available():
|
||||||
|
db_version = settings.server.get('db_version', 0)
|
||||||
|
if db_version < settings.DB_VERSION:
|
||||||
|
return True
|
||||||
|
if not os.path.exists(os.path.join(settings.updates_path, 'release.json')):
|
||||||
|
return False
|
||||||
|
if not os.path.exists(os.path.join(settings.config_path, 'release.json')):
|
||||||
|
return False
|
||||||
|
with open(os.path.join(settings.updates_path, 'release.json')) as fd:
|
||||||
|
release = json.load(fd)
|
||||||
|
old_version = current_version('openmedialibrary')
|
||||||
|
new_version = release['modules']['openmedialibrary']['version']
|
||||||
|
return verify(release) and old_version < new_version
|
||||||
|
|
||||||
|
def restart_oml(update=False):
|
||||||
|
if update:
|
||||||
|
get_latest_release()
|
||||||
|
subprocess.Popen([os.path.join(settings.base_dir, 'ctl'), 'restart'],
|
||||||
|
close_fds=True, start_new_session=True)
|
||||||
|
|
||||||
def get_app_version(app):
|
def get_app_version(app):
|
||||||
plist = app + '/Contents/Info.plist'
|
plist = app + '/Contents/Info.plist'
|
||||||
if os.path.exists(plist):
|
if os.path.exists(plist):
|
||||||
|
@ -214,9 +235,50 @@ def restart(data):
|
||||||
'''
|
'''
|
||||||
restart (and upgrade if upgrades are available)
|
restart (and upgrade if upgrades are available)
|
||||||
'''
|
'''
|
||||||
if data.get('update'):
|
restart_oml(data.get('update'))
|
||||||
download()
|
|
||||||
subprocess.Popen([os.path.join(settings.base_dir, 'ctl'), 'restart'],
|
|
||||||
close_fds=True, start_new_session=True)
|
|
||||||
return {}
|
return {}
|
||||||
actions.register(restart, cache=False)
|
actions.register(restart, cache=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Update(Thread):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
Thread.__init__(self)
|
||||||
|
self.daemon = True
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def status(self, status, reload=False):
|
||||||
|
from websocket import trigger_event
|
||||||
|
trigger_event('updatestatus', {
|
||||||
|
'reload': reload,
|
||||||
|
'status': status,
|
||||||
|
})
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
while update_available():
|
||||||
|
self.status('Downloading new version...')
|
||||||
|
while not download():
|
||||||
|
self.status('Download failed... (try again in 10 seconds)')
|
||||||
|
time.sleep(10)
|
||||||
|
self.status('Downloading new version...')
|
||||||
|
self.status('Installing new version...')
|
||||||
|
# install right now calls stop!
|
||||||
|
'''
|
||||||
|
if not install():
|
||||||
|
self.status('Installation failed...')
|
||||||
|
'''
|
||||||
|
restart_oml()
|
||||||
|
|
||||||
|
def update_database(self):
|
||||||
|
db_version = settings.server.get('db_version', 0)
|
||||||
|
if db_version < settings.DB_VERSION:
|
||||||
|
self.status('Migrating database...')
|
||||||
|
time.sleep(1)
|
||||||
|
settings.server['db_version'] = settings.DB_VERSION
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.status('Checking for Updates...')
|
||||||
|
self.update_database()
|
||||||
|
self.install()
|
||||||
|
self.status('Restarting...', True)
|
||||||
|
restart_oml()
|
||||||
|
|
178
static/js/oml.update.js
Normal file
178
static/js/oml.update.js
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
var animationInterval,
|
||||||
|
enableDebugMode = getLocalStorage('oml.enableDebugMode'),
|
||||||
|
omlVersion = getOMLVersion(),
|
||||||
|
oxjsPath = '/static/oxjs/' + (enableDebugMode ? 'dev' : 'min'),
|
||||||
|
terminal,
|
||||||
|
theme = getLocalStorage('Ox.theme')
|
||||||
|
&& JSON.parse(localStorage['Ox.theme'])
|
||||||
|
|| 'oxlight';
|
||||||
|
|
||||||
|
loadImages(function(images) {
|
||||||
|
loadScreen(images);
|
||||||
|
loadOxJS(loadOML);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getLocalStorage(key) {
|
||||||
|
try {
|
||||||
|
return localStorage[key];
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOMLVersion() {
|
||||||
|
var i, path, scripts = document.getElementsByTagName('script');
|
||||||
|
for (i = 0; i < scripts.length; i++) {
|
||||||
|
if(/oml.update.js/.test(scripts[i].src)) {
|
||||||
|
return scripts[i].src.replace(/.*\?/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImages(callback) {
|
||||||
|
var images = {};
|
||||||
|
images.logo = document.createElement('img');
|
||||||
|
images.logo.onload = function() {
|
||||||
|
images.logo.style.position = 'absolute';
|
||||||
|
images.logo.style.left = 0;
|
||||||
|
images.logo.style.top = 0;
|
||||||
|
images.logo.style.right = 0;
|
||||||
|
images.logo.style.bottom = '96px';
|
||||||
|
images.logo.style.width = '256px';
|
||||||
|
images.logo.style.height = '256px';
|
||||||
|
images.logo.style.margin = 'auto';
|
||||||
|
images.logo.style.MozUserSelect = 'none';
|
||||||
|
images.logo.style.MSUserSelect = 'none';
|
||||||
|
images.logo.style.OUserSelect = 'none';
|
||||||
|
images.logo.style.WebkitUserSelect = 'none';
|
||||||
|
images.loadingIcon = document.createElement('img');
|
||||||
|
images.loadingIcon.setAttribute('id', 'loadingIcon');
|
||||||
|
images.loadingIcon.style.position = 'absolute';
|
||||||
|
images.loadingIcon.style.left = '16px';
|
||||||
|
images.loadingIcon.style.top = '256px'
|
||||||
|
images.loadingIcon.style.right = 0;
|
||||||
|
images.loadingIcon.style.bottom = 0;
|
||||||
|
images.loadingIcon.style.width = '32px';
|
||||||
|
images.loadingIcon.style.height = '32px';
|
||||||
|
images.loadingIcon.style.margin = 'auto';
|
||||||
|
images.loadingIcon.style.MozUserSelect = 'none';
|
||||||
|
images.loadingIcon.style.MSUserSelect = 'none';
|
||||||
|
images.loadingIcon.style.OUserSelect = 'none';
|
||||||
|
images.loadingIcon.style.WebkitUserSelect = 'none';
|
||||||
|
images.loadingIcon.src = oxjsPath
|
||||||
|
+ '/UI/themes/' + theme + '/svg/symbolLoading.svg';
|
||||||
|
callback(images);
|
||||||
|
};
|
||||||
|
images.logo.src = '/static/png/oml.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOML(browserSupported) {
|
||||||
|
window.oml = Ox.App({
|
||||||
|
name: 'oml',
|
||||||
|
socket: 'ws://' + document.location.host + '/ws',
|
||||||
|
url: '/api/'
|
||||||
|
}).bindEvent({
|
||||||
|
load: function(data) {
|
||||||
|
data.browserSupported = browserSupported;
|
||||||
|
oml.ui = {};
|
||||||
|
|
||||||
|
oml.ui.status = Ox.Element()
|
||||||
|
.css({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: '50px',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingLeft: '16px',
|
||||||
|
color: '#999999'
|
||||||
|
}).appendTo(Ox.$('#loadingScreen'));
|
||||||
|
oml.ui.status.html('Updating Open Media Library...');
|
||||||
|
},
|
||||||
|
updatestatus: function(data) {
|
||||||
|
oml.ui.status.html(data.status);
|
||||||
|
oml.reload = data.reload;
|
||||||
|
},
|
||||||
|
close: function(data) {
|
||||||
|
},
|
||||||
|
open: function(data) {
|
||||||
|
Ox.print('socket open');
|
||||||
|
if (oml.reload) {
|
||||||
|
document.location.href = document.location.protocol + '//' + document.location.host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOxJS(callback) {
|
||||||
|
var head = document.head
|
||||||
|
|| document.getElementsByTagName('head')[0]
|
||||||
|
|| document.documentElement,
|
||||||
|
script = document.createElement('script');
|
||||||
|
script.onload = function() {
|
||||||
|
Ox.load({UI: {theme: theme}}, function() {
|
||||||
|
Ox.formatUpper = function(string) {
|
||||||
|
return string.toUpperCase();
|
||||||
|
};
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
script.src = oxjsPath + '/Ox.js?' + omlVersion;
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScreen(images) {
|
||||||
|
var loadingScreen = document.createElement('div');
|
||||||
|
loadingScreen.setAttribute('id', 'loadingScreen');
|
||||||
|
loadingScreen.className = 'OxScreen';
|
||||||
|
loadingScreen.style.position = 'absolute';
|
||||||
|
loadingScreen.style.width = '100%';
|
||||||
|
loadingScreen.style.height = '100%';
|
||||||
|
loadingScreen.style.backgroundColor = theme == 'oxlight' ? 'rgb(224, 224, 224)'
|
||||||
|
: theme == 'oxmedium' ? 'rgb(144, 144, 144)' : 'rgb(32, 32, 32)';
|
||||||
|
loadingScreen.style.zIndex = '1002';
|
||||||
|
loadingScreen.appendChild(images.logo);
|
||||||
|
loadingScreen.appendChild(images.loadingIcon);
|
||||||
|
// FF3.6 document.body can be undefined here
|
||||||
|
window.onload = function() {
|
||||||
|
document.body.style.margin = 0;
|
||||||
|
document.body.appendChild(loadingScreen);
|
||||||
|
startAnimation();
|
||||||
|
};
|
||||||
|
// IE8 does not call onload if already loaded before set
|
||||||
|
document.body && window.onload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScreen() {
|
||||||
|
var $loadingScreen = $('#loadingScreen');
|
||||||
|
$loadingScreen.animate({
|
||||||
|
opacity: 0
|
||||||
|
}, 1000, function() {
|
||||||
|
$loadingScreen.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAnimation() {
|
||||||
|
var css, deg = 0, loadingIcon = document.getElementById('loadingIcon'),
|
||||||
|
previousTime = +new Date();
|
||||||
|
animationInterval = setInterval(function() {
|
||||||
|
var currentTime = +new Date(),
|
||||||
|
delta = (currentTime - previousTime) / 1000;
|
||||||
|
previousTime = currentTime;
|
||||||
|
deg = Math.round((deg + delta * 360) % 360 / 30) * 30;
|
||||||
|
css = 'rotate(' + deg + 'deg)';
|
||||||
|
loadingIcon.style.MozTransform = css;
|
||||||
|
loadingIcon.style.MSTransform = css;
|
||||||
|
loadingIcon.style.OTransform = css;
|
||||||
|
loadingIcon.style.WebkitTransform = css;
|
||||||
|
loadingIcon.style.transform = css;
|
||||||
|
}, 83);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAnimation() {
|
||||||
|
clearInterval(animationInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
}());
|
Loading…
Reference in a new issue