Merge pull request #684 from mwhudson/progress-screen
better experience for partially-interactive installs
This commit is contained in:
commit
a0dae13dd7
|
@ -0,0 +1,26 @@
|
|||
# Copyright 2020 Canonical, Ltd.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from subiquitycore.context import Context
|
||||
|
||||
|
||||
class SubiquityContext(Context):
|
||||
|
||||
controller = None
|
||||
|
||||
def __init__(self, app, name, description, parent, level, childlevel=None):
|
||||
super().__init__(app, name, description, parent, level, childlevel)
|
||||
if parent is not None:
|
||||
self.controller = parent.controller
|
|
@ -35,6 +35,7 @@ class SubiquityController(BaseController):
|
|||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self.autoinstall_applied = False
|
||||
self.context.controller = self
|
||||
self.setup_autoinstall()
|
||||
|
||||
def setup_autoinstall(self):
|
||||
|
|
|
@ -106,13 +106,12 @@ class InstallProgressController(SubiquityController):
|
|||
if self.answers.get('reboot', False):
|
||||
self.reboot_clicked.set()
|
||||
|
||||
self.uu_running = False
|
||||
self.uu = None
|
||||
self._event_indent = ""
|
||||
self.unattended_upgrades_proc = None
|
||||
self.unattended_upgrades_ctx = None
|
||||
self._event_syslog_identifier = 'curtin_event.%s' % (os.getpid(),)
|
||||
self._log_syslog_identifier = 'curtin_log.%s' % (os.getpid(),)
|
||||
self.tb_extractor = TracebackExtractor()
|
||||
self.curtin_context = None
|
||||
self.curtin_event_contexts = {}
|
||||
self.confirmation = asyncio.Event()
|
||||
|
||||
def interactive(self):
|
||||
|
@ -139,7 +138,7 @@ class InstallProgressController(SubiquityController):
|
|||
crash_report = self.app.make_apport_report(
|
||||
ErrorReportKind.INSTALL_FAIL, "install failed", interrupt=False,
|
||||
**kw)
|
||||
self.progress_view.spinner.stop()
|
||||
self.progress_view.finish_all()
|
||||
self.progress_view.set_status(('info_error',
|
||||
_("An error has occurred")))
|
||||
self.start_ui()
|
||||
|
@ -158,22 +157,13 @@ class InstallProgressController(SubiquityController):
|
|||
@contextlib.contextmanager
|
||||
def install_context(self, context, name, description,
|
||||
level=None, childlevel=None):
|
||||
self._install_event_start(description)
|
||||
subcontext = context.child(name, description, level, childlevel)
|
||||
self.progress_view.event_start(subcontext, description)
|
||||
try:
|
||||
subcontext = context.child(name, description, level, childlevel)
|
||||
with subcontext:
|
||||
yield subcontext
|
||||
finally:
|
||||
self._install_event_finish()
|
||||
|
||||
def _install_event_start(self, message):
|
||||
self.progress_view.add_event(self._event_indent + message)
|
||||
self._event_indent += " "
|
||||
self.progress_view.spinner.start()
|
||||
|
||||
def _install_event_finish(self):
|
||||
self._event_indent = self._event_indent[:-2]
|
||||
self.progress_view.spinner.stop()
|
||||
self.progress_view.event_finish(subcontext)
|
||||
|
||||
def curtin_event(self, event):
|
||||
e = {
|
||||
|
@ -188,14 +178,27 @@ class InstallProgressController(SubiquityController):
|
|||
e[k[len(prefix):]] = v
|
||||
event_type = e["EVENT_TYPE"]
|
||||
if event_type == 'start':
|
||||
self._install_event_start(e["MESSAGE"])
|
||||
if self.curtin_context is not None:
|
||||
self.curtin_context.child(e["NAME"], e["MESSAGE"]).enter()
|
||||
def p(name):
|
||||
parts = name.split('/')
|
||||
for i in range(len(parts), -1, -1):
|
||||
yield '/'.join(parts[:i]), '/'.join(parts[i:])
|
||||
|
||||
curtin_ctx = None
|
||||
for pre, post in p(e["NAME"]):
|
||||
if pre in self.curtin_event_contexts:
|
||||
parent = self.curtin_event_contexts[pre]
|
||||
curtin_ctx = parent.child(post, e["MESSAGE"])
|
||||
self.curtin_event_contexts[e["NAME"]] = curtin_ctx
|
||||
break
|
||||
if curtin_ctx:
|
||||
curtin_ctx.enter()
|
||||
self.progress_view.event_start(curtin_ctx, e["MESSAGE"])
|
||||
if event_type == 'finish':
|
||||
self._install_event_finish()
|
||||
status = getattr(Status, e["RESULT"], Status.WARN)
|
||||
if self.curtin_context is not None:
|
||||
self.curtin_context.child(e["NAME"], e["MESSAGE"]).exit(status)
|
||||
curtin_ctx = self.curtin_event_contexts.pop(e["NAME"], None)
|
||||
if curtin_ctx is not None:
|
||||
curtin_ctx.exit(status)
|
||||
self.progress_view.event_finish(curtin_ctx)
|
||||
|
||||
def curtin_log(self, event):
|
||||
log_line = event['MESSAGE']
|
||||
|
@ -270,7 +273,7 @@ class InstallProgressController(SubiquityController):
|
|||
async def curtin_install(self, context):
|
||||
log.debug('curtin_install')
|
||||
self.install_state = InstallState.RUNNING
|
||||
self.curtin_context = context
|
||||
self.curtin_event_contexts[''] = context
|
||||
|
||||
self.journal_listener_handle = self.start_journald_listener(
|
||||
[self._event_syslog_identifier, self._log_syslog_identifier],
|
||||
|
@ -335,11 +338,11 @@ class InstallProgressController(SubiquityController):
|
|||
|
||||
async def drain_curtin_events(self, context):
|
||||
waited = 0.0
|
||||
while self._event_indent and waited < 5.0:
|
||||
while self.progress_view.ongoing and waited < 5.0:
|
||||
await asyncio.sleep(0.1)
|
||||
waited += 0.1
|
||||
log.debug("waited %s seconds for events to drain", waited)
|
||||
self.curtin_context = None
|
||||
self.curtin_event_contexts.pop('', None)
|
||||
|
||||
@install_step(
|
||||
"final system configuration", level="INFO", childlevel="DEBUG")
|
||||
|
@ -406,32 +409,38 @@ class InstallProgressController(SubiquityController):
|
|||
apt_conf.close()
|
||||
env = os.environ.copy()
|
||||
env["APT_CONFIG"] = apt_conf.name[len(self.model.target):]
|
||||
self.uu_running = True
|
||||
self.unattended_upgrades_ctx = context
|
||||
if self.opts.dry_run:
|
||||
self.uu = await astart_command(self.logged_command([
|
||||
"sleep", str(5/self.app.scale_factor)]), env=env)
|
||||
self.unattended_upgrades_proc = await astart_command(
|
||||
self.logged_command(
|
||||
["sleep", str(5/self.app.scale_factor)]), env=env)
|
||||
else:
|
||||
self.uu = await astart_command(self.logged_command([
|
||||
sys.executable, "-m", "curtin", "in-target", "-t", "/target",
|
||||
"--", "unattended-upgrades", "-v",
|
||||
]), env=env)
|
||||
await self.uu.communicate()
|
||||
self.uu_running = False
|
||||
self.uu = None
|
||||
self.unattended_upgrades_proc = await astart_command(
|
||||
self.logged_command([
|
||||
sys.executable, "-m", "curtin", "in-target", "-t",
|
||||
"/target", "--", "unattended-upgrades", "-v",
|
||||
]), env=env)
|
||||
await self.unattended_upgrades_proc.communicate()
|
||||
self.unattended_upgrades_proc = None
|
||||
self.unattended_upgrades_ctx = None
|
||||
os.remove(apt_conf.name)
|
||||
|
||||
async def stop_uu(self):
|
||||
self._install_event_finish()
|
||||
self._install_event_start("cancelling update")
|
||||
if self.opts.dry_run:
|
||||
await asyncio.sleep(1)
|
||||
self.uu.terminate()
|
||||
else:
|
||||
await arun_command(self.logged_command([
|
||||
'chroot', '/target',
|
||||
'/usr/share/unattended-upgrades/unattended-upgrade-shutdown',
|
||||
'--stop-only',
|
||||
]), check=True)
|
||||
async def stop_unattended_upgrades(self):
|
||||
self.progress_view.event_finish(self.unattended_upgrades_ctx)
|
||||
with self.install_context(
|
||||
self.unattended_upgrades_ctx.parent,
|
||||
"stop_unattended_uprades",
|
||||
"cancelling update"):
|
||||
if self.opts.dry_run:
|
||||
await asyncio.sleep(1)
|
||||
self.unattended_upgrades_proc.terminate()
|
||||
else:
|
||||
await arun_command(self.logged_command([
|
||||
'chroot', '/target',
|
||||
'/usr/share/unattended-upgrades/'
|
||||
'unattended-upgrade-shutdown',
|
||||
'--stop-only',
|
||||
]), check=True)
|
||||
|
||||
@install_step("copying logs to installed system")
|
||||
async def copy_logs_to_target(self, context):
|
||||
|
@ -453,8 +462,8 @@ class InstallProgressController(SubiquityController):
|
|||
log.exception("saving journal failed")
|
||||
|
||||
async def _click_reboot(self):
|
||||
if self.uu_running:
|
||||
await self.stop_uu()
|
||||
if self.unattended_upgrades_ctx is not None:
|
||||
await self.stop_unattended_uprades()
|
||||
self.reboot_clicked.set()
|
||||
|
||||
def click_reboot(self):
|
||||
|
|
|
@ -37,6 +37,7 @@ from subiquitycore.controller import Skip
|
|||
from subiquitycore.core import Application
|
||||
from subiquitycore.utils import run_command
|
||||
|
||||
from subiquity.context import SubiquityContext
|
||||
from subiquity.controllers.error import (
|
||||
ErrorReportKind,
|
||||
)
|
||||
|
@ -86,6 +87,8 @@ class Subiquity(Application):
|
|||
|
||||
project = "subiquity"
|
||||
|
||||
context_cls = SubiquityContext
|
||||
|
||||
def make_model(self):
|
||||
root = '/'
|
||||
if self.opts.dry_run:
|
||||
|
@ -143,6 +146,10 @@ class Subiquity(Application):
|
|||
self._apport_files = []
|
||||
|
||||
self.autoinstall_config = {}
|
||||
self.report_to_show = None
|
||||
self.show_progress_handle = None
|
||||
self.progress_shown_time = self.aio_loop.time()
|
||||
self.progress_showing = False
|
||||
self.note_data_for_apport("SnapUpdated", str(self.updated))
|
||||
self.note_data_for_apport("UsingAnswers", str(bool(self.answers)))
|
||||
|
||||
|
@ -252,17 +259,32 @@ class Subiquity(Application):
|
|||
traceback.print_exc()
|
||||
signal.pause()
|
||||
|
||||
def report_start_event(self, name, description, level="INFO"):
|
||||
def report_start_event(self, context, description):
|
||||
# report_start_event gets called when the Reporting controller
|
||||
# is being loaded...
|
||||
Reporting = getattr(self.controllers, "Reporting", None)
|
||||
if Reporting is not None:
|
||||
Reporting.report_start_event(name, description, level)
|
||||
Reporting.report_start_event(
|
||||
context.full_name(), description, context.level)
|
||||
InstallProgress = getattr(self.controllers, "InstallProgress", None)
|
||||
if InstallProgress is not None and context.controller is not None:
|
||||
if self.interactive() and not context.controller.interactive():
|
||||
msg = context.full_name()
|
||||
if description:
|
||||
msg += ': ' + description
|
||||
self.controllers.InstallProgress.progress_view.event_start(
|
||||
context, msg)
|
||||
|
||||
def report_finish_event(self, name, description, status, level="INFO"):
|
||||
def report_finish_event(self, context, description, status):
|
||||
Reporting = getattr(self.controllers, "Reporting", None)
|
||||
if Reporting is not None:
|
||||
Reporting.report_finish_event(name, description, status, level)
|
||||
Reporting.report_finish_event(
|
||||
context.full_name(), description, status, context.level)
|
||||
InstallProgress = getattr(self.controllers, "InstallProgress", None)
|
||||
if InstallProgress is not None and context.controller is not None:
|
||||
if self.interactive() and not context.controller.interactive():
|
||||
self.controllers.InstallProgress.progress_view.event_finish(
|
||||
context)
|
||||
|
||||
def confirm_install(self):
|
||||
self.install_confirmed = True
|
||||
|
@ -307,19 +329,44 @@ class Subiquity(Application):
|
|||
super().select_initial_screen(index)
|
||||
for report in self.controllers.Error.reports:
|
||||
if report.kind == ErrorReportKind.UI and not report.seen:
|
||||
log.debug("showing new error %r", report.base)
|
||||
self.show_error_report(report)
|
||||
self.report_to_show = report
|
||||
return
|
||||
|
||||
def select_screen(self, new):
|
||||
if new.interactive():
|
||||
if self.show_progress_handle is not None:
|
||||
self.ui.block_input = False
|
||||
self.show_progress_handle.cancel()
|
||||
self.show_progress_handle = None
|
||||
if self.progress_showing:
|
||||
shown_for = self.aio_loop.time() - self.progress_shown_time
|
||||
remaining = 1.0 - shown_for
|
||||
if remaining > 0.0:
|
||||
self.aio_loop.call_later(
|
||||
remaining, self.select_screen, new)
|
||||
return
|
||||
self.progress_showing = False
|
||||
super().select_screen(new)
|
||||
if self.report_to_show is not None:
|
||||
log.debug("showing new error %r", self.report_to_show.base)
|
||||
self.show_error_report(self.report_to_show)
|
||||
self.report_to_show = None
|
||||
elif self.autoinstall_config and not new.autoinstall_applied:
|
||||
if self.interactive() and self.show_progress_handle is None:
|
||||
self.ui.block_input = True
|
||||
self.show_progress_handle = self.aio_loop.call_later(
|
||||
0.1, self._show_progress)
|
||||
schedule_task(self._apply(new))
|
||||
else:
|
||||
new.configured()
|
||||
raise Skip
|
||||
|
||||
def _show_progress(self):
|
||||
self.ui.block_input = False
|
||||
self.progress_shown_time = self.aio_loop.time()
|
||||
self.progress_showing = True
|
||||
self.ui.set_body(self.controllers.InstallProgress.progress_view)
|
||||
|
||||
async def _apply(self, controller):
|
||||
with controller.context.child("apply_autoinstall_config"):
|
||||
await controller.apply_autoinstall_config()
|
||||
|
|
|
@ -25,6 +25,12 @@ log = logging.getLogger('subiquity.ui.frame')
|
|||
|
||||
class SubiquityUI(SubiquityCoreUI):
|
||||
|
||||
block_input = False
|
||||
|
||||
def __init__(self, app):
|
||||
self.right_icon = HelpButton(app)
|
||||
super().__init__()
|
||||
|
||||
def keypress(self, size, key):
|
||||
if not self.block_input:
|
||||
return super().keypress(size, key)
|
||||
|
|
|
@ -45,9 +45,12 @@ class MyLineBox(LineBox):
|
|||
|
||||
|
||||
class ProgressView(BaseView):
|
||||
|
||||
title = _("Install progress")
|
||||
|
||||
def __init__(self, controller):
|
||||
self.controller = controller
|
||||
self.spinner = Spinner(controller.app.aio_loop)
|
||||
self.ongoing = {} # context -> line containing a spinner
|
||||
|
||||
self.reboot_btn = Toggleable(ok_btn(
|
||||
_("Reboot Now"), on_press=self.reboot))
|
||||
|
@ -55,6 +58,8 @@ class ProgressView(BaseView):
|
|||
_("View error report"), on_press=self.view_error)
|
||||
self.view_log_btn = other_btn(
|
||||
_("View full log"), on_press=self.view_log)
|
||||
self.continue_btn = other_btn(
|
||||
_("Continue"), on_press=self.continue_)
|
||||
|
||||
self.event_listbox = ListBox()
|
||||
self.event_linebox = MyLineBox(self.event_listbox)
|
||||
|
@ -87,17 +92,32 @@ class ProgressView(BaseView):
|
|||
lb.set_focus(len(walker) - 1)
|
||||
lb.set_focus_valign('bottom')
|
||||
|
||||
def add_event(self, text):
|
||||
def event_start(self, context, message):
|
||||
self.event_finish(context.parent)
|
||||
walker = self.event_listbox.base_widget.body
|
||||
if len(walker) > 0:
|
||||
# Remove the spinner from the line it is currently on, if
|
||||
# there is one.
|
||||
walker[-1] = walker[-1][0]
|
||||
# Add spinner to the line we are inserting.
|
||||
new_line = Columns([('pack', Text(text)), ('pack', self.spinner)],
|
||||
dividechars=1)
|
||||
indent = ' ' * (context.full_name().count('/') - 2)
|
||||
spinner = Spinner(self.controller.app.aio_loop)
|
||||
spinner.start()
|
||||
new_line = Columns([
|
||||
('pack', Text(indent + message)),
|
||||
('pack', spinner),
|
||||
], dividechars=1)
|
||||
self.ongoing[context] = len(walker)
|
||||
self._add_line(self.event_listbox, new_line)
|
||||
|
||||
def event_finish(self, context):
|
||||
index = self.ongoing.pop(context, None)
|
||||
if index is None:
|
||||
return
|
||||
walker = self.event_listbox.base_widget.body
|
||||
spinner = walker[index][1]
|
||||
spinner.stop()
|
||||
walker[index] = walker[index][0]
|
||||
|
||||
def finish_all(self):
|
||||
for context in self.ongoing.copy():
|
||||
self.event_finish(context)
|
||||
|
||||
def add_log_line(self, text):
|
||||
self._add_line(self.log_listbox, Text(text))
|
||||
|
||||
|
@ -129,6 +149,21 @@ class ProgressView(BaseView):
|
|||
self.event_buttons.base_widget.focus_position = 1
|
||||
self.event_pile.base_widget.focus_position = 2
|
||||
|
||||
def show_continue(self):
|
||||
btns = [self.continue_btn, self.reboot_btn]
|
||||
self._set_buttons(btns)
|
||||
self.event_buttons.base_widget.focus_position = 0
|
||||
self.event_pile.base_widget.focus_position = 2
|
||||
|
||||
def continue_(self, sender=None):
|
||||
self.controller.app.next_screen()
|
||||
|
||||
def hide_continue(self):
|
||||
btns = [self.view_log_btn]
|
||||
self._set_buttons(btns)
|
||||
self.event_buttons.base_widget.focus_position = 0
|
||||
self.event_pile.base_widget.focus_position = 2
|
||||
|
||||
def show_error(self, crash_report):
|
||||
btns = [self.view_log_btn, self.view_error_btn, self.reboot_btn]
|
||||
self._set_buttons(btns)
|
||||
|
@ -183,7 +218,12 @@ class InstallConfirmation(Stretchy):
|
|||
|
||||
def ok(self, sender):
|
||||
self.app.confirm_install()
|
||||
self.parent.remove_overlay()
|
||||
if isinstance(self.parent, ProgressView):
|
||||
self.parent.hide_continue()
|
||||
self.app.next_screen()
|
||||
|
||||
def cancel(self, sender):
|
||||
self.parent.remove_overlay()
|
||||
if isinstance(self.parent, ProgressView):
|
||||
self.parent.show_continue()
|
||||
|
|
|
@ -56,15 +56,15 @@ class Context:
|
|||
self.childlevel = childlevel
|
||||
|
||||
@classmethod
|
||||
def new(self, app):
|
||||
return Context(app, app.project, "", None, "INFO")
|
||||
def new(cls, app):
|
||||
return cls(app, app.project, "", None, "INFO")
|
||||
|
||||
def child(self, name, description="", level=None, childlevel=None):
|
||||
if level is None:
|
||||
level = self.childlevel
|
||||
return Context(self.app, name, description, self, level, childlevel)
|
||||
return type(self)(self.app, name, description, self, level, childlevel)
|
||||
|
||||
def _name(self):
|
||||
def full_name(self):
|
||||
c = self
|
||||
names = []
|
||||
while c is not None:
|
||||
|
@ -75,13 +75,12 @@ class Context:
|
|||
def enter(self, description=None):
|
||||
if description is None:
|
||||
description = self.description
|
||||
self.app.report_start_event(self._name(), description, self.level)
|
||||
self.app.report_start_event(self, description)
|
||||
|
||||
def exit(self, description=None, result=Status.SUCCESS):
|
||||
if description is None:
|
||||
description = self.description
|
||||
self.app.report_finish_event(
|
||||
self._name(), description, result, self.level)
|
||||
self.app.report_finish_event(self, description, result)
|
||||
|
||||
def __enter__(self):
|
||||
self.enter()
|
||||
|
|
|
@ -315,6 +315,7 @@ class Application:
|
|||
# instance.
|
||||
|
||||
make_ui = SubiquityCoreUI
|
||||
context_cls = Context
|
||||
|
||||
def __init__(self, opts):
|
||||
self.debug_flags = ()
|
||||
|
@ -366,7 +367,7 @@ class Application:
|
|||
self.new_event_loop()
|
||||
self.urwid_loop = None
|
||||
self.controllers = ControllerSet(self, self.controllers)
|
||||
self.context = Context.new(self)
|
||||
self.context = self.context_cls.new(self)
|
||||
|
||||
def new_event_loop(self):
|
||||
new_loop = asyncio.new_event_loop()
|
||||
|
@ -472,15 +473,14 @@ class Application:
|
|||
self.controllers.index = controller_index - 1
|
||||
self.next_screen()
|
||||
|
||||
def report_start_event(self, name, description, level):
|
||||
# See context.py for what calls these.
|
||||
log = logging.getLogger(name)
|
||||
level = getattr(logging, level)
|
||||
def report_start_event(self, context, description):
|
||||
log = logging.getLogger(context.full_name())
|
||||
level = getattr(logging, context.level)
|
||||
log.log(level, "start: %s", description)
|
||||
|
||||
def report_finish_event(self, name, description, status, level):
|
||||
log = logging.getLogger(name)
|
||||
level = getattr(logging, level)
|
||||
def report_finish_event(self, context, description, status):
|
||||
log = logging.getLogger(context.full_name())
|
||||
level = getattr(logging, context.level)
|
||||
log.log(level, "finish: %s %s", description, status.name)
|
||||
|
||||
# EventLoop -------------------------------------------------------------------
|
||||
|
|
Loading…
Reference in New Issue