Merge pull request #684 from mwhudson/progress-screen

better experience for partially-interactive installs
This commit is contained in:
Michael Hudson-Doyle 2020-04-07 23:00:00 +12:00 committed by GitHub
commit a0dae13dd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 208 additions and 80 deletions

26
subiquity/context.py Normal file
View File

@ -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

View File

@ -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):

View File

@ -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)
try:
subcontext = context.child(name, description, level, childlevel)
self.progress_view.event_start(subcontext, description)
try:
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,30 +409,36 @@ 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",
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.uu.communicate()
self.uu_running = False
self.uu = None
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")
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.uu.terminate()
self.unattended_upgrades_proc.terminate()
else:
await arun_command(self.logged_command([
'chroot', '/target',
'/usr/share/unattended-upgrades/unattended-upgrade-shutdown',
'/usr/share/unattended-upgrades/'
'unattended-upgrade-shutdown',
'--stop-only',
]), check=True)
@ -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):

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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 -------------------------------------------------------------------