Add start/stop queuing, Remove unused layers on update/uninstall
This commit is contained in:
parent
1105aea148
commit
31372ac3e1
@ -15,6 +15,8 @@ class ActionItemType(Enum):
|
|||||||
APP_INSTALL = 6
|
APP_INSTALL = 6
|
||||||
APP_UPDATE = 7
|
APP_UPDATE = 7
|
||||||
APP_UNINSTALL = 8
|
APP_UNINSTALL = 8
|
||||||
|
APP_START = 9
|
||||||
|
APP_STOP = 10
|
||||||
|
|
||||||
class ActionItem:
|
class ActionItem:
|
||||||
def __init__(self, action_type, key, action, show_progress=True):
|
def __init__(self, action_type, key, action, show_progress=True):
|
||||||
@ -36,7 +38,6 @@ class ActionAppQueue:
|
|||||||
def __init__(self, action):
|
def __init__(self, action):
|
||||||
self.action = action
|
self.action = action
|
||||||
self.queue = []
|
self.queue = []
|
||||||
self.started = False
|
|
||||||
self.exception = None
|
self.exception = None
|
||||||
self.index = 0
|
self.index = 0
|
||||||
|
|
||||||
@ -53,13 +54,21 @@ class ActionAppQueue:
|
|||||||
self.queue.append(ActionItem(ActionItemType.APP_INSTALL, app.name, app.install, False))
|
self.queue.append(ActionItem(ActionItemType.APP_INSTALL, app.name, app.install, False))
|
||||||
|
|
||||||
def update_app(self, app):
|
def update_app(self, app):
|
||||||
|
self.queue.append(ActionItem(ActionItemType.APP_STOP, app.name, app.stop, False))
|
||||||
self.queue.append(ActionItem(ActionItemType.APP_DOWNLOAD, app.name, app.download))
|
self.queue.append(ActionItem(ActionItemType.APP_DOWNLOAD, app.name, app.download))
|
||||||
self.queue.append(ActionItem(ActionItemType.APP_UNPACK, app.name, app.unpack_downloaded))
|
self.queue.append(ActionItem(ActionItemType.APP_UNPACK, app.name, app.unpack_downloaded))
|
||||||
self.queue.append(ActionItem(ActionItemType.APP_UPDATE, app.name, app.update, False))
|
self.queue.append(ActionItem(ActionItemType.APP_UPDATE, app.name, app.update, False))
|
||||||
|
|
||||||
def uninstall_app(self, app):
|
def uninstall_app(self, app):
|
||||||
|
self.queue.append(ActionItem(ActionItemType.APP_STOP, app.name, app.stop, False))
|
||||||
self.queue.append(ActionItem(ActionItemType.APP_UNINSTALL, app.name, app.uninstall, False))
|
self.queue.append(ActionItem(ActionItemType.APP_UNINSTALL, app.name, app.uninstall, False))
|
||||||
|
|
||||||
|
def start_app(self, app):
|
||||||
|
self.queue.append(ActionItem(ActionItemType.APP_START, app.name, app.start, False))
|
||||||
|
|
||||||
|
def stop_app(self, app):
|
||||||
|
self.queue.append(ActionItem(ActionItemType.APP_STOP, app.name, app.stop, False))
|
||||||
|
|
||||||
def process(self):
|
def process(self):
|
||||||
for item in self.queue:
|
for item in self.queue:
|
||||||
self.index += 1
|
self.index += 1
|
||||||
|
@ -50,13 +50,13 @@ def unregister_app(name):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@locked(CONF_LOCK)
|
@locked(CONF_LOCK)
|
||||||
def set_host_value(key, value):
|
def set_host(key, value):
|
||||||
load()
|
load()
|
||||||
data['host'][key] = value
|
data['host'][key] = value
|
||||||
save()
|
save()
|
||||||
|
|
||||||
@locked(CONF_LOCK)
|
@locked(CONF_LOCK)
|
||||||
def set_app_value(name, key, value):
|
def set_app(name, key, value):
|
||||||
load()
|
load()
|
||||||
data['apps'][name][key] = value
|
data['apps'][name][key] = value
|
||||||
save()
|
save()
|
||||||
|
@ -9,12 +9,10 @@ from cryptography.hazmat.primitives import hashes, serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
|
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
|
||||||
|
|
||||||
from . import config
|
|
||||||
from .paths import ACME_CRON, CERT_PUB_FILE, CERT_KEY_FILE
|
from .paths import ACME_CRON, CERT_PUB_FILE, CERT_KEY_FILE
|
||||||
|
|
||||||
def create_selfsigned_cert():
|
def create_selfsigned_cert(domain):
|
||||||
# Create selfsigned certificate with wildcard alternative subject name
|
# Create selfsigned certificate with wildcard alternative subject name
|
||||||
domain = config.get_host()['domain']
|
|
||||||
private_key = ec.generate_private_key(ec.SECP384R1(), default_backend())
|
private_key = ec.generate_private_key(ec.SECP384R1(), default_backend())
|
||||||
public_key = private_key.public_key()
|
public_key = private_key.public_key()
|
||||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domain)])
|
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domain)])
|
||||||
@ -59,5 +57,5 @@ def get_cert_info():
|
|||||||
def adminpwd_hash(password):
|
def adminpwd_hash(password):
|
||||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
def adminpwd_verify(password):
|
def adminpwd_verify(password, hash):
|
||||||
return bcrypt.checkpw(password.encode(), config.get_host()['adminpwd'].encode())
|
return bcrypt.checkpw(password.encode(), hash.encode())
|
||||||
|
@ -8,8 +8,9 @@ import urllib
|
|||||||
from spoc.app import App
|
from spoc.app import App
|
||||||
from spoc.config import ONLINE_BASE_URL
|
from spoc.config import ONLINE_BASE_URL
|
||||||
from spoc.container import Container, ContainerState
|
from spoc.container import Container, ContainerState
|
||||||
|
from spoc.depsolver import DepSolver
|
||||||
|
|
||||||
from . import crypto, net, templates
|
from . import config, crypto, net, templates
|
||||||
from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, MOTD_FILE, NGINX_DIR
|
from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, MOTD_FILE, NGINX_DIR
|
||||||
|
|
||||||
def register_app(app, host, login, password):
|
def register_app(app, host, login, password):
|
||||||
@ -68,7 +69,7 @@ def update_password(oldpassword, newpassword):
|
|||||||
pwinput = f'{oldpassword}\n{newpassword}'.encode()
|
pwinput = f'{oldpassword}\n{newpassword}'.encode()
|
||||||
partition_uuid = open('/etc/crypttab').read().split()[1][5:]
|
partition_uuid = open('/etc/crypttab').read().split()[1][5:]
|
||||||
partition_name = subprocess.run(['/sbin/blkid', '-U', partition_uuid], check=True, stdout=subprocess.PIPE).stdout.decode().strip()
|
partition_name = subprocess.run(['/sbin/blkid', '-U', partition_uuid], check=True, stdout=subprocess.PIPE).stdout.decode().strip()
|
||||||
subprocess.run(['cryptsetup', 'luksChangeKey', partition_name], input=pwinput, check=True)
|
subprocess.run(['/sbin/cryptsetup', 'luksChangeKey', partition_name], input=pwinput, check=True)
|
||||||
# Update bcrypt-hashed password in config
|
# Update bcrypt-hashed password in config
|
||||||
hash = crypto.adminpwd_hash(newpassword)
|
hash = crypto.adminpwd_hash(newpassword)
|
||||||
config.set_host('adminpwd', hash)
|
config.set_host('adminpwd', hash)
|
||||||
@ -126,28 +127,13 @@ def shutdown_vm():
|
|||||||
def reboot_vm():
|
def reboot_vm():
|
||||||
subprocess.run(['/sbin/reboot'])
|
subprocess.run(['/sbin/reboot'])
|
||||||
|
|
||||||
def start_app(item):
|
def start_app(app_name, queue):
|
||||||
# Start the actual app service
|
# Enqueue application start
|
||||||
app = item.key
|
queue.start_app(App(app_name))
|
||||||
if app in config.get_apps() and not is_app_started(app):
|
|
||||||
start_service(app)
|
|
||||||
|
|
||||||
def start_service(service):
|
def stop_app(app_name, queue):
|
||||||
subprocess.run(['/sbin/service', service, 'start'], check=True)
|
# Enqueue application stop
|
||||||
|
queue.stop_app(App(app_name))
|
||||||
def stop_app(item):
|
|
||||||
# Stop the actual app service
|
|
||||||
app = item.key
|
|
||||||
if app in config.get_apps() and is_app_started(app):
|
|
||||||
stop_service(app)
|
|
||||||
# Stop the app service's dependencies if they are not used by another running app
|
|
||||||
deps = get_services_deps()
|
|
||||||
for dep in get_service_deps(app):
|
|
||||||
if not any([is_app_started(d) for d in deps[dep]]):
|
|
||||||
stop_service(dep)
|
|
||||||
|
|
||||||
def stop_service(service):
|
|
||||||
subprocess.run(['/sbin/service', service, 'stop'], check=True)
|
|
||||||
|
|
||||||
def update_app_visibility(app_name, visible):
|
def update_app_visibility(app_name, visible):
|
||||||
# Update visibility for the app in the configuration
|
# Update visibility for the app in the configuration
|
||||||
@ -155,7 +141,7 @@ def update_app_visibility(app_name, visible):
|
|||||||
|
|
||||||
def update_app_autostart(app_name, enabled):
|
def update_app_autostart(app_name, enabled):
|
||||||
# Add/remove the app to OpenRC default runlevel
|
# Add/remove the app to OpenRC default runlevel
|
||||||
App(app_name).set_autostart(enabled)
|
App(app_name, False).set_autostart(enabled)
|
||||||
|
|
||||||
def is_app_started(app_name):
|
def is_app_started(app_name):
|
||||||
# Assume that the main container has always the same name as app
|
# Assume that the main container has always the same name as app
|
||||||
@ -166,9 +152,10 @@ def is_app_autostarted(app_name):
|
|||||||
return App(app_name, False).autostart
|
return App(app_name, False).autostart
|
||||||
|
|
||||||
def install_app(app_name, queue):
|
def install_app(app_name, queue):
|
||||||
# Main installation function. Wrapper for download, registration and install script
|
# Enqueue layers and application download, registration and install script execution
|
||||||
required_images = []
|
required_images = []
|
||||||
for container in repo_online.get_app(app_name)['containers'].values():
|
new_containers = repo_online.get_app(app_name)['containers'].values()
|
||||||
|
for container in new_containers:
|
||||||
required_images.extend(repo_online.get_image(container['image'])['layers'])
|
required_images.extend(repo_online.get_image(container['image'])['layers'])
|
||||||
local_images = repo_local.get_images()
|
local_images = repo_local.get_images()
|
||||||
for layer in set(required_images):
|
for layer in set(required_images):
|
||||||
@ -177,21 +164,46 @@ def install_app(app_name, queue):
|
|||||||
queue.install_app(App(app_name, False, False))
|
queue.install_app(App(app_name, False, False))
|
||||||
|
|
||||||
def uninstall_app(app_name, queue):
|
def uninstall_app(app_name, queue):
|
||||||
# Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration
|
# Enqueue application uninstall script execution and removal
|
||||||
queue.uninstall_app(App(app_name, False))
|
app = App(app_name, False)
|
||||||
|
queue.uninstall_app(app)
|
||||||
|
# Remove unused layers
|
||||||
|
removed_containers = [container.name for container in app.containers]
|
||||||
|
retained_containers = [definition for name,definition in repo_local.get_containers() if name not in removed_containers]
|
||||||
|
remove_unused_layers(retained_containers, queue)
|
||||||
|
|
||||||
def update_app(app_name, queue):
|
def update_app(app_name, queue):
|
||||||
# Main update function. Wrapper for download and update script
|
# Enqueue layers and application download, registration and update script execution
|
||||||
required_images = []
|
required_images = []
|
||||||
for container in repo_online.get_app(app_name)['containers'].values():
|
new_containers = repo_online.get_app(app_name)['containers'].values()
|
||||||
|
for container in new_containers:
|
||||||
required_images.extend(repo_online.get_image(container['image'])['layers'])
|
required_images.extend(repo_online.get_image(container['image'])['layers'])
|
||||||
local_images = repo_local.get_images()
|
local_images = repo_local.get_images()
|
||||||
for layer in set(required_images):
|
for layer in set(required_images):
|
||||||
if layer not in local_images:
|
if layer not in local_images:
|
||||||
queue.download_image(Image(layer, False))
|
queue.download_image(Image(layer, False))
|
||||||
queue.update_app(App(app_name, False))
|
app = App(app_name, False)
|
||||||
|
queue.update_app(app)
|
||||||
|
# Remove unused layers
|
||||||
|
removed_containers = [container.name for container in app.containers]
|
||||||
|
retained_containers = [definition for name,definition in repo_local.get_containers() if name not in removed_containers] + new_containers
|
||||||
|
remove_unused_layers(retained_containers, queue)
|
||||||
|
|
||||||
def update_repo_settings(url, username, password):
|
def remove_unused_layers(retained_containers, queue):
|
||||||
|
# Enqueue removal of images which won't be used in any locally defined containers anymore
|
||||||
|
used_images = set()
|
||||||
|
for definition in retained_containers:
|
||||||
|
used_images.update(definition['layers'])
|
||||||
|
# Build dependency tree to safely remove the images in order of dependency
|
||||||
|
depsolver = DepSolver()
|
||||||
|
for image in set(repo_local.get_images()) - used_images:
|
||||||
|
image = Image(image)
|
||||||
|
depsolver.add(image.name, set(image.layers) - used_images, image)
|
||||||
|
# Enqueue the removal actions
|
||||||
|
for image in reversed(depsolver.solve()):
|
||||||
|
queue.delete_image(image)
|
||||||
|
|
||||||
|
def update_repo_conf(url, username, password):
|
||||||
# Include credentials in the repo URL and save to SPOC config
|
# Include credentials in the repo URL and save to SPOC config
|
||||||
spoc_config = configparser.ConfigParser()
|
spoc_config = configparser.ConfigParser()
|
||||||
spoc_config.read('/etc/spoc/spoc.conf')
|
spoc_config.read('/etc/spoc/spoc.conf')
|
||||||
@ -202,9 +214,9 @@ def update_repo_settings(url, username, password):
|
|||||||
with open('/etc/spoc/spoc.conf', 'w') as f:
|
with open('/etc/spoc/spoc.conf', 'w') as f:
|
||||||
config.write(f)
|
config.write(f)
|
||||||
|
|
||||||
def get_repo_settings():
|
def get_repo_conf():
|
||||||
# Parse the SPOC config repo URL and return as tuple
|
# Parse the SPOC config repo URL and return as tuple
|
||||||
parts = urllib.parse.urlsplit(ONLINE_BASE_URL)
|
parts = urllib.parse.urlsplit(ONLINE_BASE_URL)
|
||||||
netloc = parts.netloc.split('@', 1)[1] if parts.username or parts.password else parts.netloc
|
netloc = parts.netloc.split('@', 1)[1] if parts.username or parts.password else parts.netloc
|
||||||
url = urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
url = urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||||
return (url, parts.username, parts.password)
|
return {'url':url, 'username':parts.username}
|
||||||
|
@ -125,7 +125,8 @@ class WSGIApp:
|
|||||||
def login_action(self, request):
|
def login_action(self, request):
|
||||||
password = request.form['password']
|
password = request.form['password']
|
||||||
redir = request.form['redir']
|
redir = request.form['redir']
|
||||||
if crypto.adminpwd_verify(password):
|
hash = config.get_host()['adminpwd']
|
||||||
|
if crypto.adminpwd_verify(password, hash):
|
||||||
request.session['admin'] = True
|
request.session['admin'] = True
|
||||||
return redirect(f'/{redir}')
|
return redirect(f'/{redir}')
|
||||||
request.session['msg'] = f'login:error:{request.session.lang.bad_password()}'
|
request.session['msg'] = f'login:error:{request.session.lang.bad_password()}'
|
||||||
@ -170,8 +171,8 @@ class WSGIApp:
|
|||||||
repo_error = request.session.lang.repo_unavailable()
|
repo_error = request.session.lang.repo_unavailable()
|
||||||
table = self.render_setup_apps_table(request)
|
table = self.render_setup_apps_table(request)
|
||||||
message = self.get_session_message(request)
|
message = self.get_session_message(request)
|
||||||
repo_url, repo_user, _ = vmmgr.get_repo_settings()
|
repo_conf = vmmgr.get_repo_conf()
|
||||||
return self.render_html('setup-apps.html', request, repo_url=repo_url, repo_user=repo_user, repo_error=repo_error, table=table, message=message)
|
return self.render_html('setup-apps.html', request, repo_conf=repo_conf, repo_error=repo_error, table=table, message=message)
|
||||||
|
|
||||||
def render_setup_apps_table(self, request):
|
def render_setup_apps_table(self, request):
|
||||||
lang = request.session.lang
|
lang = request.session.lang
|
||||||
@ -210,27 +211,28 @@ class WSGIApp:
|
|||||||
actions = None
|
actions = None
|
||||||
else:
|
else:
|
||||||
# Display task/subtask progress
|
# Display task/subtask progress
|
||||||
if app_queue.action == vmmgr.start_app:
|
action_item = app_queue.queue[app_queue.index-1]
|
||||||
|
if action_item.type in (ActionItemType.IMAGE_DOWNLOAD, ActionItemType.APP_DOWNLOAD):
|
||||||
|
status = lang.status_downloading(action_item.key)
|
||||||
|
elif action_item.type in (ActionItemType.IMAGE_UNPACK, ActionItemType.APP_UNPACK):
|
||||||
|
status = lang.status_unpacking(action_item.key)
|
||||||
|
elif action_item.type == ActionItemType.IMAGE_DELETE:
|
||||||
|
status = lang.status_deleting(action_item.key)
|
||||||
|
elif action_item.type == ActionItemType.APP_START:
|
||||||
status = lang.status_starting()
|
status = lang.status_starting()
|
||||||
elif app_queue.action == vmmgr.stop_app:
|
elif action_item.type == ActionItemType.APP_STOP:
|
||||||
status = lang.status_stopping()
|
status = lang.status_stopping()
|
||||||
else:
|
elif action_item.type == ActionItemType.APP_INSTALL:
|
||||||
action_item = app_queue.queue[app_queue.index-1]
|
status = lang.status_installing()
|
||||||
if action_item.type in (ActionItemType.IMAGE_DOWNLOAD, ActionItemType.APP_DOWNLOAD):
|
elif action_item.type == ActionItemType.APP_UPDATE:
|
||||||
status = lang.status_downloading(action_item.key)
|
status = lang.status_updating()
|
||||||
elif action_item.type in (ActionItemType.IMAGE_UNPACK, ActionItemType.APP_UNPACK):
|
elif action_item.type == ActionItemType.APP_UNINSTALL:
|
||||||
status = lang.status_unpacking(action_item.key)
|
status = lang.status_uninstalling()
|
||||||
elif action_item.type == ActionItemType.IMAGE_DELETE:
|
if app_queue.action not in (vmmgr.start_app, vmmgr.stop_app):
|
||||||
status = lang.status_deleting(action_item.key)
|
# For tasks other than start/stop which have only a single subtask, display also index of the subtask in queue
|
||||||
elif action_item.type == ActionItemType.APP_INSTALL:
|
|
||||||
status = lang.status_installing(action_item.key)
|
|
||||||
elif action_item.type == ActionItemType.APP_UPDATE:
|
|
||||||
status = lang.status_updating(action_item.key)
|
|
||||||
elif action_item.type == ActionItemType.APP_UNINSTALL:
|
|
||||||
status = lang.status_uninstalling(action_item.key)
|
|
||||||
status = f'[{app_queue.index}/{len(app_queue.queue)}] {status}'
|
status = f'[{app_queue.index}/{len(app_queue.queue)}] {status}'
|
||||||
if action_item.show_progress:
|
if action_item.show_progress:
|
||||||
status = f'{status} ({floor(current_action.units_done/current_action.units_total*100)} %)'
|
status = f'{status} ({floor(current_action.units_done/current_action.units_total*100)} %)'
|
||||||
actions = '<div class="loader"></div>'
|
actions = '<div class="loader"></div>'
|
||||||
else:
|
else:
|
||||||
# Display queued (pending, not started) task
|
# Display queued (pending, not started) task
|
||||||
@ -239,11 +241,11 @@ class WSGIApp:
|
|||||||
elif app_queue.action == vmmgr.stop_app:
|
elif app_queue.action == vmmgr.stop_app:
|
||||||
status = lang.status_stopping()
|
status = lang.status_stopping()
|
||||||
elif app_queue.action == vmmgr.install_app:
|
elif app_queue.action == vmmgr.install_app:
|
||||||
status = lang.status_installing('')
|
status = lang.status_installing()
|
||||||
elif app_queue.action == vmmgr.uninstall_app:
|
|
||||||
status = lang.status_uninstalling('')
|
|
||||||
elif app_queue.action == vmmgr.update_app:
|
elif app_queue.action == vmmgr.update_app:
|
||||||
status = lang.status_updating('')
|
status = lang.status_updating()
|
||||||
|
elif app_queue.action == vmmgr.uninstall_app:
|
||||||
|
status = lang.status_uninstalling()
|
||||||
status = f'{status} ({lang.status_queued()})'
|
status = f'{status} ({lang.status_queued()})'
|
||||||
actions = '<div class="loader"></div>'
|
actions = '<div class="loader"></div>'
|
||||||
else:
|
else:
|
||||||
@ -348,7 +350,7 @@ class WSGIApp:
|
|||||||
if not validator.is_valid_repo_url(url):
|
if not validator.is_valid_repo_url(url):
|
||||||
request.session['msg'] = f'repo:error:{request.session.lang.invalid_url(url)}'
|
request.session['msg'] = f'repo:error:{request.session.lang.invalid_url(url)}'
|
||||||
else:
|
else:
|
||||||
vmmgr.update_repo_settings(url, request.form['repousername'], request.form['repopassword'])
|
vmmgr.update_repo_conf(url, request.form['repousername'], request.form['repopassword'])
|
||||||
request.session['msg'] = f'repo:info:{request.session.lang.repo_updated()}'
|
request.session['msg'] = f'repo:info:{request.session.lang.repo_updated()}'
|
||||||
return redirect('/setup-apps')
|
return redirect('/setup-apps')
|
||||||
|
|
||||||
@ -406,7 +408,7 @@ class WSGIApp:
|
|||||||
return self.render_json({'error': request.session.lang.password_mismatch()})
|
return self.render_json({'error': request.session.lang.password_mismatch()})
|
||||||
if request.form['newpassword'] == '':
|
if request.form['newpassword'] == '':
|
||||||
return self.render_json({'error': request.session.lang.password_empty()})
|
return self.render_json({'error': request.session.lang.password_empty()})
|
||||||
# No need to explicitly validate old password, update_luks_password will raise exception if it's wrong
|
# No need to explicitly validate old password, vmmgr.update_password will raise exception if it's wrong
|
||||||
vmmgr.update_password(request.form['oldpassword'], request.form['newpassword'])
|
vmmgr.update_password(request.form['oldpassword'], request.form['newpassword'])
|
||||||
except:
|
except:
|
||||||
return self.render_json({'error': request.session.lang.bad_password()})
|
return self.render_json({'error': request.session.lang.bad_password()})
|
||||||
|
@ -41,9 +41,9 @@ class WSGILang:
|
|||||||
'status_stopped': 'Zastavena',
|
'status_stopped': 'Zastavena',
|
||||||
'status_downloading': 'Stahuje se {}',
|
'status_downloading': 'Stahuje se {}',
|
||||||
'status_unpacking': 'Rozbaluje se {}',
|
'status_unpacking': 'Rozbaluje se {}',
|
||||||
'status_installing': 'Instaluje se {}',
|
'status_installing': 'Instaluje se',
|
||||||
'status_updating': 'Aktualizuje se {}',
|
'status_updating': 'Aktualizuje se',
|
||||||
'status_uninstalling': 'Odinstalovává se {}',
|
'status_uninstalling': 'Odinstalovává se',
|
||||||
'status_not_installed': 'Není nainstalována',
|
'status_not_installed': 'Není nainstalována',
|
||||||
'action_start': 'Spustit',
|
'action_start': 'Spustit',
|
||||||
'action_stop': 'Zastavit',
|
'action_stop': 'Zastavit',
|
||||||
|
@ -26,11 +26,11 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>URL serveru:</td>
|
<td>URL serveru:</td>
|
||||||
<td><input type="text" name="repourl" value="{{ repo_url }}"></td>
|
<td><input type="text" name="repourl" value="{{ repo_conf['url'] }}"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Uživatelské jméno:</td>
|
<td>Uživatelské jméno:</td>
|
||||||
<td><input type="text" name="repousername" value="{{ repo_user }}"></td>
|
<td><input type="text" name="repousername" value="{{ repo_conf['username'] }}"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Heslo:</td>
|
<td>Heslo:</td>
|
||||||
|
Loading…
Reference in New Issue
Block a user