mirror of
https://github.com/flarum/framework.git
synced 2025-02-21 02:33:55 +08:00
feat: extension bisect (#3980)
* feat: extension bisect * Apply fixes from StyleCI * chore: review * Apply suggestions from code review * feat: add stop bisect button * feat: redirect to result extension page Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com>
This commit is contained in:
parent
e0025df3e7
commit
b02f8190ea
@ -8,9 +8,13 @@ import FormSectionGroup, { FormSection } from './FormSectionGroup';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import InfoTile from '../../common/components/InfoTile';
|
||||
import { MaintenanceMode } from '../../common/Application';
|
||||
import Button from '../../common/components/Button';
|
||||
import classList from '../../common/utils/classList';
|
||||
import ExtensionBisect from './ExtensionBisect';
|
||||
|
||||
export default class AdvancedPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
|
||||
searchDriverOptions: Record<string, Record<string, string>> = {};
|
||||
urlRequestedModalHasBeenShown = false;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
@ -36,6 +40,11 @@ export default class AdvancedPage<CustomAttrs extends IPageAttrs = IPageAttrs> e
|
||||
}
|
||||
|
||||
content() {
|
||||
if (m.route.param('modal') === 'extension-bisect' && !this.urlRequestedModalHasBeenShown) {
|
||||
this.urlRequestedModalHasBeenShown = true;
|
||||
setTimeout(() => app.modal.show(ExtensionBisect), 150);
|
||||
}
|
||||
|
||||
return [
|
||||
<Form className="AdvancedPage-container">
|
||||
<FormSectionGroup>{this.sectionItems().toArray()}</FormSectionGroup>
|
||||
@ -104,6 +113,7 @@ export default class AdvancedPage<CustomAttrs extends IPageAttrs = IPageAttrs> e
|
||||
help: app.translator.trans('core.admin.advanced.maintenance.help'),
|
||||
setting: 'maintenance_mode',
|
||||
refreshAfterSaving: true,
|
||||
disabled: app.data.bisecting,
|
||||
options: {
|
||||
[MaintenanceMode.NO_MAINTENANCE]: app.translator.trans('core.admin.advanced.maintenance.options.' + MaintenanceMode.NO_MAINTENANCE),
|
||||
[MaintenanceMode.HIGH_MAINTENANCE]: {
|
||||
@ -161,6 +171,18 @@ export default class AdvancedPage<CustomAttrs extends IPageAttrs = IPageAttrs> e
|
||||
<strong className="helpText">{app.translator.trans('core.admin.advanced.maintenance.options.' + app.data.maintenanceMode)}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.advanced.maintenance.bisect.label')}</label>
|
||||
<p className="helpText">{app.translator.trans('core.admin.advanced.maintenance.bisect.help')}</p>
|
||||
<Button
|
||||
className={classList('Button', { 'Button--warning': app.data.bisecting })}
|
||||
onclick={() => app.modal.show(ExtensionBisect)}
|
||||
disabled={app.data.maintenanceMode && app.data.maintenanceMode !== MaintenanceMode.LOW_MAINTENANCE}
|
||||
icon="fas fa-bug"
|
||||
>
|
||||
{app.translator.trans('core.admin.advanced.maintenance.bisect.' + (app.data.bisecting ? 'continue_button_text' : 'begin_button_text'))}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</FormSection>
|
||||
);
|
||||
|
@ -24,6 +24,26 @@ export default class DashboardPage extends AdminPage {
|
||||
availableWidgets(): ItemList<Children> {
|
||||
const items = new ItemList<Children>();
|
||||
|
||||
if (app.data.bisecting) {
|
||||
items.add(
|
||||
'bisecting',
|
||||
<AlertWidget
|
||||
alert={{
|
||||
type: 'error',
|
||||
dismissible: false,
|
||||
controls: [
|
||||
<Link className="Button Button--link" href={app.route('advanced', { modal: 'extension-bisect' })}>
|
||||
{app.translator.trans('core.lib.notices.bisecting_continue')}
|
||||
</Link>,
|
||||
],
|
||||
}}
|
||||
>
|
||||
{app.translator.trans('core.lib.notices.bisecting')}
|
||||
</AlertWidget>,
|
||||
120
|
||||
);
|
||||
}
|
||||
|
||||
if (app.data.maintenanceMode) {
|
||||
items.add(
|
||||
'maintenanceMode',
|
||||
|
166
framework/core/js/src/admin/components/ExtensionBisect.tsx
Normal file
166
framework/core/js/src/admin/components/ExtensionBisect.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import Modal, { IDismissibleOptions, type IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import Mithril from 'mithril';
|
||||
import app from '../app';
|
||||
import Button from '../../common/components/Button';
|
||||
import Form from '../../common/components/Form';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import Icon from '../../common/components/Icon';
|
||||
|
||||
type BisectResult = {
|
||||
stepsLeft: number;
|
||||
relevantEnabled: string[];
|
||||
relevantDisabled: string[];
|
||||
extension: string | null;
|
||||
};
|
||||
|
||||
export default class ExtensionBisect<CustomAttrs extends IInternalModalAttrs = IInternalModalAttrs> extends Modal<CustomAttrs> {
|
||||
private result = Stream<BisectResult | null>(null);
|
||||
private bisecting = Stream<boolean>(app.data.bisecting || false);
|
||||
|
||||
protected static readonly isDismissibleViaCloseButton: boolean = true;
|
||||
protected static readonly isDismissibleViaEscKey: boolean = false;
|
||||
protected static readonly isDismissibleViaBackdropClick: boolean = false;
|
||||
|
||||
protected get dismissibleOptions(): IDismissibleOptions {
|
||||
return {
|
||||
viaCloseButton: !this.bisecting(),
|
||||
viaEscKey: !this.bisecting(),
|
||||
viaBackdropClick: !this.bisecting(),
|
||||
};
|
||||
}
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
if (m.route.param('modal') !== 'extension-bisect') {
|
||||
window.history.replaceState({}, '', window.location.pathname + '#' + app.route('advanced', { modal: 'extension-bisect' }));
|
||||
}
|
||||
}
|
||||
|
||||
className(): string {
|
||||
return 'ExtensionBisectModal Modal--small';
|
||||
}
|
||||
|
||||
title(): Mithril.Children {
|
||||
return app.translator.trans('core.admin.advanced.maintenance.bisect_modal.title');
|
||||
}
|
||||
|
||||
content(): Mithril.Children {
|
||||
const result = this.result();
|
||||
|
||||
if (result && result.extension) {
|
||||
const extension = app.data.extensions[result.extension];
|
||||
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<Form className="Form--centered">
|
||||
<p className="helpText">{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.result_description')}</p>
|
||||
<div className="ExtensionBisectModal-extension">
|
||||
<div className="ExtensionBisectModal-extension-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? <Icon name={extension.icon.name} /> : ''}
|
||||
</div>
|
||||
<div className="ExtensionBisectModal-extension-info">
|
||||
<div className="ExtensionBisectModal-extension-name">{extension.extra['flarum-extension'].title}</div>
|
||||
<div className="ExtensionBisectModal-extension-version">
|
||||
<span className="ExtensionBisectModal-extension-version">{extension.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="Button Button--primary" onclick={() => this.hide(extension.id)}>
|
||||
{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.end_button')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<Form className="Form--centered">
|
||||
<p className="helpText">{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.description')}</p>
|
||||
<p className="helpText">
|
||||
{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.' + (this.bisecting() ? 'steps_left' : 'total_steps'), {
|
||||
steps: this.stepsLeft(),
|
||||
})}
|
||||
</p>
|
||||
{this.bisecting() ? (
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.issue_question')}</label>
|
||||
<p className="helpText">{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.issue_question_help')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{this.bisecting() ? (
|
||||
<>
|
||||
<div className="ButtonGroup ButtonGroup--block">
|
||||
<Button className="Button Button--danger" onclick={() => this.submit(true)} loading={this.loading}>
|
||||
{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.yes_button')}
|
||||
</Button>
|
||||
<Button className="Button Button--success" onclick={() => this.submit(false)} loading={this.loading}>
|
||||
{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.no_button')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button className="Button Button--primary Button--block" onclick={() => this.submit(null, true)} loading={this.loading}>
|
||||
{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.stop_button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button className="Button Button--primary Button--block" onclick={() => this.submit(true)} loading={this.loading}>
|
||||
{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.start_button')}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
stepsLeft(): number {
|
||||
if (this.result()) {
|
||||
return this.result()!.stepsLeft;
|
||||
}
|
||||
|
||||
let steps;
|
||||
|
||||
if (this.bisecting()) {
|
||||
const { low, high } = JSON.parse(app.data.settings.extension_bisect_state);
|
||||
steps = Math.ceil(Math.log2(high - low)) + 1;
|
||||
} else {
|
||||
steps = Math.ceil(Math.log2(JSON.parse(app.data.settings.extensions_enabled).length)) + 1;
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
submit(issue: boolean | null, end: boolean = false) {
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/extension-bisect',
|
||||
body: { issue, end },
|
||||
})
|
||||
.then((response) => {
|
||||
this.loading = false;
|
||||
this.bisecting(!end);
|
||||
this.result(response as BisectResult);
|
||||
m.redraw();
|
||||
|
||||
if (end) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hide(extension?: string) {
|
||||
this.attrs.animateHide(() => {
|
||||
if (extension) {
|
||||
m.route.set(app.route('extension', { id: extension }));
|
||||
} else {
|
||||
m.route.set(app.route('advanced'));
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
@ -138,6 +138,7 @@ export interface ApplicationData {
|
||||
resources: SavedModelData[];
|
||||
session: { userId: number; csrfToken: string };
|
||||
maintenanceMode?: MaintenanceMode;
|
||||
bisecting?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
@ -165,7 +165,7 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
private get dismissibleOptions(): IDismissibleOptions {
|
||||
protected get dismissibleOptions(): IDismissibleOptions {
|
||||
return (this.constructor as typeof Modal).dismissibleOptions;
|
||||
}
|
||||
}
|
||||
|
@ -40,12 +40,33 @@ export default class Notices extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
if (app.data.bisecting) {
|
||||
items.add(
|
||||
'bisecting',
|
||||
<Alert
|
||||
type="error"
|
||||
dismissible={false}
|
||||
className="Alert--bisecting"
|
||||
containerClassName="container"
|
||||
controls={[
|
||||
<a className="Button Button--link" target="_blank" href={app.forum.attribute('adminUrl') + '#/advanced?modal=extension-bisect'}>
|
||||
{app.translator.trans('core.lib.notices.bisecting_continue')}
|
||||
</a>,
|
||||
]}
|
||||
>
|
||||
{app.translator.trans('core.lib.notices.bisecting')}
|
||||
</Alert>,
|
||||
90
|
||||
);
|
||||
}
|
||||
|
||||
if (app.data.maintenanceMode) {
|
||||
items.add(
|
||||
'maintenanceMode',
|
||||
<Alert type="error" dismissible={false} className="Alert--maintenanceMode" containerClassName="container">
|
||||
{app.translator.trans('core.lib.notices.maintenance_mode_' + app.data.maintenanceMode)}
|
||||
</Alert>
|
||||
</Alert>,
|
||||
80
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
@import "admin/AdminHeader";
|
||||
@import "admin/AdminNav";
|
||||
@import "admin/AdvancedPage";
|
||||
@import "admin/CreateUserModal";
|
||||
@import "admin/DashboardPage";
|
||||
@import "admin/AlertWidget";
|
||||
|
14
framework/core/less/admin/AdvancedPage.less
Normal file
14
framework/core/less/admin/AdvancedPage.less
Normal file
@ -0,0 +1,14 @@
|
||||
.ExtensionBisectModal-extension {
|
||||
padding: 2rem 0;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--body-bg);
|
||||
}
|
||||
|
||||
.ExtensionBisectModal-extension-icon {
|
||||
--size: 60px;
|
||||
}
|
||||
|
||||
.ExtensionBisectModal-extension-name {
|
||||
font-size: 15px;
|
||||
margin: 1rem 0 0;
|
||||
}
|
@ -33,6 +33,11 @@
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.Form--centered & {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&, & > .Button {
|
||||
@ -40,6 +45,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ButtonGroup--block {
|
||||
width: 100%;
|
||||
|
||||
.Button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Buttons
|
||||
// --------------------------------------------------
|
||||
@ -174,6 +187,12 @@
|
||||
.Button--danger {
|
||||
.Button--color-auto('control-danger');
|
||||
}
|
||||
.Button--success {
|
||||
.Button--color-auto('control-success');
|
||||
}
|
||||
.Button--warning {
|
||||
.Button--color-auto('control-warning');
|
||||
}
|
||||
.Button--more {
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
|
@ -27,6 +27,10 @@
|
||||
--control-color: @control-color;
|
||||
--control-danger-bg: @control-danger-bg;
|
||||
--control-danger-color: @control-danger-color;
|
||||
--control-success-bg: @control-success-bg;
|
||||
--control-success-color: @control-success-color;
|
||||
--control-warning-bg: @control-warning-bg;
|
||||
--control-warning-color: @control-warning-color;
|
||||
--control-body-bg-mix: mix(@control-bg, @body-bg, 50%);
|
||||
--control-muted-color: lighten(@control-color, 40%);
|
||||
|
||||
@ -93,6 +97,8 @@
|
||||
.Button--color-vars(@control-color, @control-bg, 'button');
|
||||
.Button--color-vars(@body-bg, @primary-color, 'button-primary');
|
||||
.Button--color-vars(@control-danger-color, @control-danger-bg, 'control-danger');
|
||||
.Button--color-vars(@control-success-color, @control-success-bg, 'control-success');
|
||||
.Button--color-vars(@control-warning-color, @control-warning-bg, 'control-warning');
|
||||
.Button--color-vars(@muted-more-color, fade(@muted-more-color, 30%), 'muted-more');
|
||||
.Button--color-vars(@control-color, @body-bg, 'button-inverted');
|
||||
|
||||
|
@ -38,6 +38,10 @@
|
||||
@control-color: @muted-color;
|
||||
@control-danger-bg: #fdd;
|
||||
@control-danger-color: #d66;
|
||||
@control-success-bg: #B4F1AF;
|
||||
@control-success-color: #33722D;
|
||||
@control-warning-bg: #fff2ae;
|
||||
@control-warning-color: #ad6c00;
|
||||
|
||||
@overlay-bg: fade(@secondary-color, 90%);
|
||||
|
||||
@ -60,6 +64,10 @@
|
||||
@control-color: @muted-color;
|
||||
@control-danger-bg: #411;
|
||||
@control-danger-color: #a88;
|
||||
@control-success-bg: #B4F1AF;
|
||||
@control-success-color: #33722D;
|
||||
@control-warning-bg: #fff2ae;
|
||||
@control-warning-color: #ad6c00;
|
||||
|
||||
@overlay-bg: fade(darken(@body-bg, 5%), 90%);
|
||||
|
||||
|
@ -11,6 +11,28 @@ core:
|
||||
advanced:
|
||||
description: "Configure advanced settings for your forum."
|
||||
maintenance:
|
||||
bisect:
|
||||
begin_button_text: Begin Bisect
|
||||
continue_button_text: Continue Bisect
|
||||
label: Extension Bisect
|
||||
help: Helps to identify the extension causing some issue you observed. This automatically puts the forum in maintenance mode (low) until the process is over.
|
||||
bisect_modal:
|
||||
description: >
|
||||
This puts the forum in maintenance mode (low) until the process is over.
|
||||
In each step some extensions will be disabled until we find the one causing the issue. So do not be surprised to see missing features or different theme design.
|
||||
Keep this page open, and try to reproduce the issue after each step in a separate page. You can always come back to this page if you mistakenly close it.
|
||||
end_button: Close and go to extension page
|
||||
issue_question: Does the issue still occur?
|
||||
issue_question_help: Try reproducing the issue in a separate page and answer based on the result.
|
||||
no_button: No
|
||||
result_description: >
|
||||
Forum is no longer in maintenance mode. Extension bisect is over. Based on your responses to each step, the cause of the issue is the following extension:
|
||||
start_button: Start bisect
|
||||
stop_button: Stop bisect
|
||||
steps_left: "Steps left: {steps}"
|
||||
total_steps: This will take around {steps} steps.
|
||||
title: Extension Bisect
|
||||
yes_button: Yes
|
||||
config_override:
|
||||
label: Your <code>config.php</code> file is overriding these settings.
|
||||
help: You can still change these settings here, but they will not take effect until you set <code>offline</code> to <code>0</code> in your <code>config.php</code> file.
|
||||
@ -714,6 +736,8 @@ core:
|
||||
|
||||
# These translations are used in forum & admin notices.
|
||||
notices:
|
||||
bisecting: Running an extension bisect process to determine which extension is causing an issue.
|
||||
bisecting_continue: Continue bisecting
|
||||
maintenance_mode_low: Down for maintenance. Only administrators can access the forum.
|
||||
maintenance_mode_safe: Down for maintenance with safe mode. Only administrators can access the forum and no extensions are booted.
|
||||
|
||||
|
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Extension\Bisect;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class ExtensionBisectController implements RequestHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected Bisect $bisect
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
RequestUtil::getActor($request)->assertAdmin();
|
||||
|
||||
$issue = boolval(Arr::get($request->getParsedBody(), 'issue'));
|
||||
|
||||
if (Arr::get($request->getParsedBody(), 'end')) {
|
||||
$this->bisect->end();
|
||||
|
||||
return new JsonResponse([], 204);
|
||||
}
|
||||
|
||||
$result = $this->bisect->break()->checkIssueUsing(function () use ($issue) {
|
||||
return $issue;
|
||||
})->run();
|
||||
|
||||
return new JsonResponse($result ?? [], $result ? 200 : 204);
|
||||
}
|
||||
}
|
@ -307,6 +307,13 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|
||||
$route->toController(Controller\ShowExtensionReadmeController::class)
|
||||
);
|
||||
|
||||
// Extension bisect
|
||||
$map->post(
|
||||
'/extension-bisect',
|
||||
'extension-bisect',
|
||||
$route->toController(Controller\ExtensionBisectController::class)
|
||||
);
|
||||
|
||||
// Update settings
|
||||
$map->post(
|
||||
'/settings',
|
||||
|
@ -13,6 +13,7 @@ use Carbon\Carbon;
|
||||
use Flarum\Console\Cache\Factory;
|
||||
use Flarum\Database\Console\MigrateCommand;
|
||||
use Flarum\Database\Console\ResetCommand;
|
||||
use Flarum\Extension\Console\BisectCommand;
|
||||
use Flarum\Extension\Console\ToggleExtensionCommand;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Console\AssetsPublishCommand;
|
||||
@ -66,7 +67,8 @@ class ConsoleServiceProvider extends AbstractServiceProvider
|
||||
ResetCommand::class,
|
||||
ScheduleListCommand::class,
|
||||
ScheduleRunCommand::class,
|
||||
ToggleExtensionCommand::class
|
||||
ToggleExtensionCommand::class,
|
||||
BisectCommand::class,
|
||||
// Used internally to create DB dumps before major releases.
|
||||
// \Flarum\Database\Console\GenerateDumpCommand::class
|
||||
];
|
||||
|
150
framework/core/src/Extension/Bisect.php
Normal file
150
framework/core/src/Extension/Bisect.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Extension;
|
||||
|
||||
use Closure;
|
||||
use Composer\Command\ClearCacheCommand;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
class Bisect
|
||||
{
|
||||
protected BisectState $state;
|
||||
|
||||
/**
|
||||
* When running bisect across multiple processes (such as multiple HTTP requests),
|
||||
* this flag can be used to stop the bisect process after the first step it completes.
|
||||
*/
|
||||
protected bool $break = false;
|
||||
|
||||
protected bool $issueChecked = false;
|
||||
protected ?Closure $isIssuePresent = null;
|
||||
|
||||
public function __construct(
|
||||
protected ExtensionManager $extensions,
|
||||
protected SettingsRepositoryInterface $settings,
|
||||
protected ClearCacheCommand $clearCache,
|
||||
) {
|
||||
$this->state = BisectState::continueOrStart(
|
||||
$ids = $this->extensions->getEnabled(),
|
||||
0,
|
||||
count($ids) - 1
|
||||
);
|
||||
}
|
||||
|
||||
public function break(bool $break = true): self
|
||||
{
|
||||
$this->break = $break;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function checkIssueUsing(Closure $isIssuePresent): self
|
||||
{
|
||||
$this->isIssuePresent = $isIssuePresent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* 'stepsLeft': int,
|
||||
* 'relevantEnabled': string[],
|
||||
* 'relevantDisabled': string[],
|
||||
* 'extension': ?string,
|
||||
* }|null
|
||||
*/
|
||||
public function run(): ?array
|
||||
{
|
||||
if (is_null($this->isIssuePresent)) {
|
||||
throw new RuntimeException('You must provide a closure to check if the issue is present.');
|
||||
}
|
||||
|
||||
$this->settings->set('maintenance_mode', 'low');
|
||||
|
||||
return $this->bisect($this->state);
|
||||
}
|
||||
|
||||
protected function bisect(BisectState $state): ?array
|
||||
{
|
||||
[$ids, $low, $high] = [$state->ids, $state->low, $state->high];
|
||||
|
||||
if ($low > $high) {
|
||||
$this->end();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$mid = (int) (($low + $high) / 2);
|
||||
$enabled = array_slice($ids, 0, $mid + 1);
|
||||
|
||||
$relevantEnabled = array_slice($ids, $low, $mid - $low + 1);
|
||||
$relevantDisabled = array_slice($ids, $mid + 1, $high - $mid);
|
||||
$stepsLeft = round(log($high - $low + 1, 2));
|
||||
|
||||
$this->rotateExtensions($enabled);
|
||||
|
||||
$current = [
|
||||
'stepsLeft' => $stepsLeft,
|
||||
'relevantEnabled' => $relevantEnabled,
|
||||
'relevantDisabled' => $relevantDisabled,
|
||||
'extension' => null,
|
||||
];
|
||||
|
||||
if (! $this->break || ! $this->issueChecked) {
|
||||
$issue = ($this->isIssuePresent)($current);
|
||||
$this->issueChecked = true;
|
||||
} else {
|
||||
return $current;
|
||||
}
|
||||
|
||||
if (count($relevantEnabled) === 1 && $issue) {
|
||||
return $this->foundIssue($relevantEnabled[0]);
|
||||
}
|
||||
|
||||
if (count($relevantDisabled) === 1 && ! $issue) {
|
||||
return $this->foundIssue($relevantDisabled[0]);
|
||||
}
|
||||
|
||||
if ($issue) {
|
||||
return $this->bisect($this->state->advance($low, $mid));
|
||||
} else {
|
||||
return $this->bisect($this->state->advance($mid + 1, $high));
|
||||
}
|
||||
}
|
||||
|
||||
protected function foundIssue(string $id): array
|
||||
{
|
||||
$this->end();
|
||||
|
||||
return [
|
||||
'stepsLeft' => 0,
|
||||
'relevantEnabled' => [],
|
||||
'relevantDisabled' => [],
|
||||
'extension' => $id,
|
||||
];
|
||||
}
|
||||
|
||||
public function end(): void
|
||||
{
|
||||
$this->settings->set('extensions_enabled', json_encode($this->state->ids));
|
||||
$this->settings->set('maintenance_mode', 'none');
|
||||
$this->state->end();
|
||||
$this->clearCache->run(new ArrayInput([]), new NullOutput());
|
||||
}
|
||||
|
||||
protected function rotateExtensions(array $enabled): void
|
||||
{
|
||||
$this->settings->set('extensions_enabled', json_encode($enabled));
|
||||
$this->clearCache->run(new ArrayInput([]), new NullOutput());
|
||||
}
|
||||
}
|
99
framework/core/src/Extension/BisectState.php
Normal file
99
framework/core/src/Extension/BisectState.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Extension;
|
||||
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class BisectState
|
||||
{
|
||||
protected static SettingsRepositoryInterface $settings;
|
||||
public const SETTING = 'extension_bisect_state';
|
||||
|
||||
public function __construct(
|
||||
public array $ids,
|
||||
public int $low,
|
||||
public int $high,
|
||||
) {
|
||||
}
|
||||
|
||||
public function advance(int $low, int $high): self
|
||||
{
|
||||
$this->low = $low;
|
||||
$this->high = $high;
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
public function save(): self
|
||||
{
|
||||
self::$settings->set(self::SETTING, json_encode($this->toArray()));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'ids' => $this->ids,
|
||||
'low' => $this->low,
|
||||
'high' => $this->high,
|
||||
];
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): BisectState
|
||||
{
|
||||
if (! isset($data['ids'], $data['low'], $data['high'])) {
|
||||
throw new InvalidArgumentException('Invalid data array');
|
||||
}
|
||||
|
||||
return new self(
|
||||
$data['ids'],
|
||||
$data['low'],
|
||||
$data['high']
|
||||
);
|
||||
}
|
||||
|
||||
public static function continue(): ?BisectState
|
||||
{
|
||||
$data = self::$settings->get(self::SETTING);
|
||||
|
||||
if (! $data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::fromArray(json_decode($data, true));
|
||||
}
|
||||
|
||||
public static function continueOrStart(array $ids, int $low, int $high): BisectState
|
||||
{
|
||||
$state = self::continue();
|
||||
|
||||
if ($state) {
|
||||
return $state;
|
||||
}
|
||||
|
||||
return new self(
|
||||
$ids,
|
||||
$low,
|
||||
$high
|
||||
);
|
||||
}
|
||||
|
||||
public static function end(): void
|
||||
{
|
||||
self::$settings->delete(self::SETTING);
|
||||
}
|
||||
|
||||
public static function setSettings(SettingsRepositoryInterface $settings): void
|
||||
{
|
||||
self::$settings = $settings;
|
||||
}
|
||||
}
|
66
framework/core/src/Extension/Console/BisectCommand.php
Normal file
66
framework/core/src/Extension/Console/BisectCommand.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Extension\Console;
|
||||
|
||||
use Flarum\Extension\Bisect;
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'extension:bisect',
|
||||
description: 'Find which extensions is causing an issue. This command will progressively enable and disable extensions to find the one causing the issue. This command will put the forum in maintenance mode.'
|
||||
)]
|
||||
class BisectCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
protected ExtensionManager $extensions,
|
||||
protected Bisect $bisect,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->output->writeln('<info>Starting bisect...</info>');
|
||||
|
||||
$start = true;
|
||||
|
||||
$result = $this->bisect->checkIssueUsing(function (array $step) use (&$start) {
|
||||
if (! $start) {
|
||||
$this->output->writeln("<info>Continuing bisect... {$step['stepsLeft']} steps left</info>");
|
||||
$this->output->writeln('<info>Issue is in one of: ('.implode(', ', $step['relevantEnabled']).') or ('.implode(', ', $step['relevantDisabled']).')</info>');
|
||||
} else {
|
||||
$start = false;
|
||||
}
|
||||
|
||||
return $this->output->confirm('Does the issue still occur?');
|
||||
})->run();
|
||||
|
||||
if (! $result) {
|
||||
$this->output->writeln('<error>Could not find the extension causing the issue.</error>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->foundIssue($result['extension']);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function foundIssue(string $id): void
|
||||
{
|
||||
$extension = $this->extensions->getExtension($id);
|
||||
|
||||
$title = $extension->getTitle();
|
||||
|
||||
$this->output->writeln("<info>Extension causing the issue: $title</info>");
|
||||
}
|
||||
}
|
@ -11,6 +11,7 @@ namespace Flarum\Extension;
|
||||
|
||||
use Flarum\Extension\Event\Disabling;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
@ -33,8 +34,10 @@ class ExtensionServiceProvider extends AbstractServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(Dispatcher $events): void
|
||||
public function boot(Dispatcher $events, SettingsRepositoryInterface $settings): void
|
||||
{
|
||||
BisectState::setSettings($settings);
|
||||
|
||||
$events->listen(
|
||||
Disabling::class,
|
||||
DefaultLanguagePackGuard::class
|
||||
|
@ -9,10 +9,12 @@
|
||||
|
||||
namespace Flarum\Frontend\Content;
|
||||
|
||||
use Flarum\Extension\BisectState;
|
||||
use Flarum\Foundation\MaintenanceMode;
|
||||
use Flarum\Frontend\Document;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Locale\LocaleManager;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
class CorePayload
|
||||
@ -20,6 +22,7 @@ class CorePayload
|
||||
public function __construct(
|
||||
private readonly LocaleManager $locales,
|
||||
private readonly MaintenanceMode $maintenance,
|
||||
private readonly SettingsRepositoryInterface $settings
|
||||
) {
|
||||
}
|
||||
|
||||
@ -49,6 +52,10 @@ class CorePayload
|
||||
$payload['maintenanceMode'] = $this->maintenance->mode();
|
||||
}
|
||||
|
||||
if ($this->settings->get(BisectState::SETTING)) {
|
||||
$payload['bisecting'] = true;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user