UX: improve UI of software update page & display more info. (#214)

This PR will refresh the software update page so that self-hosters can more easily understand the plugins they have installed and which plugins need updates, and so that they are able to quickly and easily update them (or update everything).

Co-authored-by: Martin Brennan <mjrbrennan@gmail.com>
This commit is contained in:
Vinoth Kannan 2024-07-09 00:14:26 +05:30 committed by GitHub
parent c8d8da7b44
commit e29c6b1504
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 341 additions and 165 deletions

View File

@ -1,62 +0,0 @@
<tr>
<td>
{{#if this.officialRepoBadge}}
{{d-icon
this.officialRepoBadge
translatedTitle=this.officialRepoBadgeTitle
class="check-circle"
}}
{{/if}}
</td>
<td>
<a href={{@repo.url}}>{{@repo.name}}</a>
<span class="current commit-hash" title={{@repo.version}}>
{{@repo.prettyVersion}}
</span>
</td>
<td>
{{#if @repo.checkingStatus}}
{{i18n "admin.docker.checking"}}
{{else if @repo.upToDate}}
{{i18n "admin.docker.up_to_date"}}
{{else}}
<div class="new-version">
<h4>{{i18n "admin.docker.new_version_available"}}</h4>
<ul>
<li>
{{i18n "admin.docker.latest_version"}}
<span class="new commit-hash" title={{@repo.latestVersion}}>
{{@repo.prettyLatestVersion}}
</span>
</li>
<li>
{{i18n "admin.docker.last_updated"}}
{{#if @repo.latest.date}}
{{format-date @repo.latest.date}}
{{else}}
&mdash;
{{/if}}
</li>
<li class="new-commits">
{{new-commits
@repo.latest.commits_behind
@repo.version
@repo.latest.version
@repo.url
}}
</li>
</ul>
<DButton
@action={{this.upgrade}}
@disabled={{this.upgradeDisabled}}
@translatedLabel={{this.upgradeButtonLabel}}
class="upgrade-button"
/>
</div>
{{/if}}
</td>
</tr>

View File

@ -1,56 +0,0 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import I18n from "I18n";
export default class RepoStatus extends Component {
@service router;
@service upgradeStore;
get upgradeDisabled() {
// Allow to see the currently running update
if (this.args.upgradingRepo) {
return false;
}
// Disable other buttons when an update is running
if (this.upgradeStore.running) {
return true;
}
// docker_manager has to be updated before other plugins
return (
!this.args.managerRepo.upToDate &&
this.args.managerRepo !== this.args.repo
);
}
get officialRepoBadge() {
if (this.args.repo.fork) {
return "exclamation-circle";
} else if (this.args.repo.official) {
return "check-circle";
}
}
get officialRepoBadgeTitle() {
if (this.args.repo.fork) {
return I18n.t("admin.docker.forked_plugin");
} else if (this.args.repo.official) {
return I18n.t("admin.docker.official_plugin");
}
}
get upgradeButtonLabel() {
if (this.args.repo.upgrading) {
return I18n.t("admin.docker.updating");
} else {
return I18n.t("admin.docker.update_action");
}
}
@action
upgrade() {
this.router.transitionTo("update.show", this.args.repo);
}
}

View File

@ -0,0 +1,121 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import FormatDate from "discourse/helpers/format-date";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "I18n";
import CommitUrl from "../helpers/commit-url";
import NewCommits from "../helpers/new-commits";
export default class RepoStatus extends Component {
@service router;
@service upgradeStore;
get upgradeDisabled() {
// Allow to see the currently running update
if (this.args.upgradingRepo) {
return false;
}
// Disable other buttons when an update is running
if (this.upgradeStore.running) {
return true;
}
// docker_manager has to be updated before other plugins
return (
!this.args.managerRepo.upToDate &&
this.args.managerRepo !== this.args.repo
);
}
get upgradeButtonLabel() {
if (this.args.repo.upgrading) {
return I18n.t("admin.docker.updating");
} else {
return I18n.t("admin.docker.update_action");
}
}
@action
upgrade() {
this.router.transitionTo("update.show", this.args.repo);
}
<template>
<tr class="repo {{if @repo.hasNewVersion 'new-version'}}">
<td>
<div class="repo__name">
{{@repo.nameTitleized}}
</div>
{{#if @repo.author}}
<div class="repo__author">
{{@repo.author}}
</div>
{{/if}}
{{#if @repo.plugin}}
<div class="repo__about">
{{@repo.plugin.about}}
{{#if @repo.linkUrl}}
<a
href={{@repo.linkUrl}}
rel="noopener noreferrer"
target="_blank"
>
{{i18n "admin.plugins.learn_more"}}
{{icon "external-link-alt"}}
</a>
{{/if}}
</div>
{{/if}}
{{#if @repo.hasNewVersion}}
<div class="repo__new-version">
{{i18n "admin.docker.new_version_available"}}
</div>
{{/if}}
</td>
<td>
{{CommitUrl "current" @repo.version @repo.prettyVersion @repo.url}}
</td>
<td>{{FormatDate @repo.latest.date leaveAgo="true"}}</td>
<td>
<ul class="repo__latest-version">
<li>
{{CommitUrl
"new"
@repo.latest.version
@repo.prettyLatestVersion
@repo.url
}}
</li>
<li class="new-commits">
{{NewCommits
@repo.latest.commits_behind
@repo.version
@repo.latest.version
@repo.url
}}
</li>
</ul>
</td>
<td class="repo__status">
{{#if @repo.checkingStatus}}
{{i18n "admin.docker.checking"}}
{{else if @repo.upToDate}}
{{i18n "admin.docker.up_to_date"}}
{{else}}
<DButton
@action={{this.upgrade}}
@disabled={{this.upgradeDisabled}}
@translatedLabel={{this.upgradeButtonLabel}}
class="upgrade-button"
/>
{{/if}}
</td>
</tr>
</template>
}

View File

@ -0,0 +1,16 @@
import { htmlSafe } from "@ember/template";
export default function commitUrl(cssClass, version, prettyVersion, url) {
if (!prettyVersion) {
return "";
}
if (!url) {
return prettyVersion;
}
const repoUrl = url.substr(0, url.search(/(\.git)?$/));
const description = `<a class='${cssClass} commit-hash' title='${version}' href='${repoUrl}/commit/${version}'>${prettyVersion}</a>`;
return new htmlSafe(description);
}

View File

@ -1,6 +1,8 @@
import { tracked } from "@glimmer/tracking";
import { cached, tracked } from "@glimmer/tracking";
import { capitalize } from "@ember/string";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import { ajax } from "discourse/lib/ajax";
import AdminPlugin from "admin/models/admin-plugin";
let loaded = [];
export let needsImageUpgrade = false;
@ -74,6 +76,7 @@ export default class Repo {
@tracked checking = false;
@tracked lastCheckedAt = null;
@tracked latest = new TrackedObject({});
@tracked plugin = null;
// model attributes
@tracked name = null;
@ -94,8 +97,12 @@ export default class Repo {
}
}
if (attributes.plugin) {
this.plugin = AdminPlugin.create(attributes.plugin);
}
for (const [key, value] of Object.entries(attributes)) {
if (key === "latest") {
if (["latest", "plugin"].includes(key)) {
continue;
}
@ -103,6 +110,31 @@ export default class Repo {
}
}
@cached
get nameTitleized() {
if (this.plugin) {
return this.plugin.nameTitleized;
}
return capitalize(this.name);
}
get linkUrl() {
if (this.plugin) {
return this.plugin.linkUrl;
}
return this.url;
}
get author() {
if (this.plugin) {
return this.plugin.author;
}
return null;
}
get checkingStatus() {
return this.unloaded || this.checking;
}
@ -111,6 +143,10 @@ export default class Repo {
return !this.upgrading && this.version === this.latest?.version;
}
get hasNewVersion() {
return !this.checkingStatus && !this.upToDate;
}
get prettyVersion() {
return this.pretty_version || this.version?.substring(0, 8);
}

View File

@ -4,6 +4,7 @@ import Repo from "../models/repo";
export default class UpgradeShow extends Route {
@service upgradeStore;
@service router;
model(params) {
if (params.id === "all") {
@ -14,6 +15,11 @@ export default class UpgradeShow extends Route {
}
async afterModel(model) {
if (!model) {
this.router.replaceWith("/404");
return;
}
if (Array.isArray(model)) {
const repos = await Repo.findLatestAll();

View File

@ -1,4 +1,21 @@
<h1>{{i18n "admin.docker.update_title"}}</h1>
<div class="updates-heading">
<h3>{{i18n "admin.docker.update_title"}}</h3>
{{#unless this.outdated}}
<DButton
disabled={{this.upgradeAllButtonDisabled}}
id="upgrade-all"
class="btn btn-primary"
type="button"
{{on "click" this.upgradeAllButton}}
>
{{#if this.allUpToDate}}
{{i18n "admin.docker.all_up_to_date"}}
{{else}}
{{i18n "admin.docker.update_all"}}
{{/if}}
</DButton>
{{/unless}}
</div>
{{#if this.outdated}}
<h2>{{i18n "admin.docker.outdated_image_header"}}</h2>
@ -18,29 +35,18 @@
</a>
</p>
{{else}}
<button
disabled={{this.upgradeAllButtonDisabled}}
id="upgrade-all"
class="btn"
type="button"
{{on "click" this.upgradeAllButton}}
>
{{#if this.allUpToDate}}
{{i18n "admin.docker.all_up_to_date"}}
{{else}}
{{i18n "admin.docker.update_all"}}
{{/if}}
</button>
<table class="table" id="repos">
<table class="table admin-repos" id="repos">
<thead>
<th></th>
<th style="width: 50%">{{i18n "admin.docker.repository"}}</th>
<th>{{i18n "admin.docker.status"}}</th>
<th style="width: 40%">{{i18n "admin.docker.repo.name"}}</th>
<th>{{i18n "admin.docker.repo.commit_hash"}}</th>
<th>{{i18n "admin.docker.repo.last_updated"}}</th>
<th>{{i18n "admin.docker.repo.latest_version"}}</th>
<th align="center">{{i18n "admin.docker.repo.status"}}</th>
</thead>
<tbody>
{{#each this.model as |repo|}}
<DockerManager::RepoStatus
<RepoStatus
@repo={{repo}}
@upgradingRepo={{repo.upgrading}}
@managerRepo={{this.managerRepo}}

View File

@ -23,6 +23,13 @@ module DockerManager
official: Plugin::Metadata::OFFICIAL_PLUGINS.include?(r.name),
}
plugin = Discourse.plugins.find { |p| p.path == "#{r.path}/plugin.rb" }
result[:plugin] = AdminPluginSerializer.new(
plugin,
scope: guardian,
root: false,
) if plugin.present?
result[:fork] = true if result[:official] &&
!r.url.starts_with?("https://github.com/discourse/")
@ -40,7 +47,7 @@ module DockerManager
response = { repos: repos }
if !Rails.env.development?
if !Rails.env.development? && !Rails.env.test?
version =
begin
File.read("/VERSION")

View File

@ -73,7 +73,7 @@
}
}
span.commit-hash {
.commit-hash {
color: #959595;
}
@ -92,10 +92,6 @@
}
}
#upgrade-all {
float: right;
}
#banner {
margin: 1rem 0;
@ -147,4 +143,62 @@
}
}
}
.repo__name {
font-weight: bold;
}
.repo__author {
font-size: var(--font-down-2);
padding: 0 0 0.25em 0;
}
.repo__new-version {
font-size: var(--font-down-1);
font-weight: bold;
padding: 0.25em 0 0 0;
}
.repo__about {
padding: 0.25em 0 0.25em 0;
}
ul.repo__latest-version {
list-style: none;
margin: 0;
}
tr.repo {
td {
padding: 1em 0;
}
&.new-version {
background-color: var(--tertiary-very-low);
td:first-child {
border-left: solid 3px var(--tertiary);
}
}
td.repo__status {
text-align: right;
padding-right: 0.5em;
}
}
.updates-heading {
display: flex;
justify-content: space-between;
margin: 2em 0;
h3 {
line-height: 40px;
margin-bottom: 0;
}
}
.admin-repos th:last-child {
text-align: center;
}
}

View File

@ -36,8 +36,14 @@ en:
update_repo: "Update %{name}"
update_successful: "Update completed successfully!"
update_tab: "Update Discourse"
update_title: "Update Discourse"
update_title: "Updates"
updating: "Updating…"
repo:
name: "Name"
commit_hash: "Commit Hash"
last_updated: "Last Updated"
latest_version: "Latest Version"
status: "Status"
logs:
staff_actions:

View File

@ -145,7 +145,7 @@ class DockerManager::Upgrader
log(message)
end
if num_workers_spun_down.positive? && !reloaded
if num_workers_spun_down.to_i.positive? && !reloaded
log "Spinning up #{num_workers_spun_down} Unicorn worker(s) that were stopped initially"
num_workers_spun_down.times { Process.kill("TTIN", unicorn_master_pid) }
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require_dependency "docker_manager/git_repo"
RSpec.describe "Admin update", type: :system do
fab!(:admin)
let(:au_page) { PageObjects::Pages::AdminUpdate.new }
before do
sign_in(admin)
au_page.visit
end
it "shows the update page" do
expect(au_page).to be_displayed
end
it "shows the core repo" do
expect(au_page).to have_repo(name: "Discourse")
end
it "shows the docker_manager plugin repo" do
expect(au_page).to have_repo(name: "Docker Manager", url: "https://meta.discourse.org/t/12655")
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminUpdate < PageObjects::Pages::Base
def visit
page.visit("/admin/update")
self
end
def displayed?
has_css?("h3", text: "Updates")
end
def has_repo?(repo)
has_css?("tr.repo .repo__name", text: repo[:name]) &&
(!repo[:url] || has_css?("tr.repo .repo__about a[href='#{repo[:url]}']"))
end
end
end
end

View File

@ -52,14 +52,14 @@ module("Integration | Component | RepoStatus", function (hooks) {
this.set("managerRepo", store.createRecord("repo", managerProps));
await render(
hbs`<DockerManager::RepoStatus @repo={{this.repo}} @managerRepo={{this.managerRepo}} />`
hbs`<RepoStatus @repo={{this.repo}} @managerRepo={{this.managerRepo}} />`
);
assert
.dom("span.current.commit-hash")
.dom("a.current.commit-hash")
.hasText("v2.2.0.beta6 +98", "tag version is used when present");
assert
.dom("span.new.commit-hash")
.dom("a.new.commit-hash")
.hasText("v2.2.0.beta6 +101", "tag version is used when present");
assert
@ -76,36 +76,32 @@ module("Integration | Component | RepoStatus", function (hooks) {
await settled();
assert.strictEqual(
query("span.current.commit-hash").textContent.trim(),
query("a.current.commit-hash").textContent.trim(),
"8f65e4f",
"commit hash is used when tag version is absent"
);
assert.strictEqual(
query("span.new.commit-hash").textContent.trim(),
query("a.new.commit-hash").textContent.trim(),
"2b006c0",
"commit hash is used when tag version is absent"
);
});
test("official check mark", async function (assert) {
test("official plugin", async function (assert) {
const store = getOwner(this).lookup("service:store");
repoProps.plugin = { name: "discourse", isOfficial: true };
this.set("repo", store.createRecord("repo", repoProps));
this.set("managerRepo", store.createRecord("repo", managerProps));
await render(
hbs`<DockerManager::RepoStatus @repo={{this.repo}} @managerRepo={{this.managerRepo}} />`
hbs`<RepoStatus @repo={{this.repo}} @managerRepo={{this.managerRepo}} />`
);
assert
.dom("svg.d-icon-check-circle")
.doesNotExist("green check is absent when not official");
this.repo.official = true;
await settled();
assert
.dom("svg.d-icon-check-circle")
.exists("green check is present when official");
assert.strictEqual(
query("div.repo__author").textContent.trim(),
"By Discourse",
"shows plugin author"
);
});
test("update button", async function (assert) {
@ -114,7 +110,7 @@ module("Integration | Component | RepoStatus", function (hooks) {
this.set("managerRepo", store.createRecord("repo", managerProps));
await render(
hbs`<DockerManager::RepoStatus @repo={{this.repo}} @managerRepo={{this.managerRepo}} />`
hbs`<RepoStatus @repo={{this.repo}} @managerRepo={{this.managerRepo}} />`
);
assert