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:
parent
c8d8da7b44
commit
e29c6b1504
|
@ -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}}
|
||||
—
|
||||
{{/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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue