diff --git a/framework/core/js/src/admin/components/AdvancedPage.tsx b/framework/core/js/src/admin/components/AdvancedPage.tsx index 37c97137ca..feca76e015 100644 --- a/framework/core/js/src/admin/components/AdvancedPage.tsx +++ b/framework/core/js/src/admin/components/AdvancedPage.tsx @@ -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 extends AdminPage { searchDriverOptions: Record> = {}; + urlRequestedModalHasBeenShown = false; oninit(vnode: Mithril.Vnode) { super.oninit(vnode); @@ -36,6 +40,11 @@ export default class AdvancedPage e } content() { + if (m.route.param('modal') === 'extension-bisect' && !this.urlRequestedModalHasBeenShown) { + this.urlRequestedModalHasBeenShown = true; + setTimeout(() => app.modal.show(ExtensionBisect), 150); + } + return [
{this.sectionItems().toArray()} @@ -104,6 +113,7 @@ export default class AdvancedPage 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 e {app.translator.trans('core.admin.advanced.maintenance.options.' + app.data.maintenanceMode)} ) : null} +
+ +

{app.translator.trans('core.admin.advanced.maintenance.bisect.help')}

+ +
); diff --git a/framework/core/js/src/admin/components/DashboardPage.tsx b/framework/core/js/src/admin/components/DashboardPage.tsx index b24fb8868c..399596b2b8 100644 --- a/framework/core/js/src/admin/components/DashboardPage.tsx +++ b/framework/core/js/src/admin/components/DashboardPage.tsx @@ -24,6 +24,26 @@ export default class DashboardPage extends AdminPage { availableWidgets(): ItemList { const items = new ItemList(); + if (app.data.bisecting) { + items.add( + 'bisecting', + + {app.translator.trans('core.lib.notices.bisecting_continue')} + , + ], + }} + > + {app.translator.trans('core.lib.notices.bisecting')} + , + 120 + ); + } + if (app.data.maintenanceMode) { items.add( 'maintenanceMode', diff --git a/framework/core/js/src/admin/components/ExtensionBisect.tsx b/framework/core/js/src/admin/components/ExtensionBisect.tsx new file mode 100644 index 0000000000..3c4dc6ab86 --- /dev/null +++ b/framework/core/js/src/admin/components/ExtensionBisect.tsx @@ -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 extends Modal { + private result = Stream(null); + private bisecting = Stream(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) { + 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 ( +
+
+

{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.result_description')}

+
+
+ {extension.icon ? : ''} +
+
+
{extension.extra['flarum-extension'].title}
+
+ {extension.version} +
+
+
+ +
+
+ ); + } + + return ( +
+
+

{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.description')}

+

+ {app.translator.trans('core.admin.advanced.maintenance.bisect_modal.' + (this.bisecting() ? 'steps_left' : 'total_steps'), { + steps: this.stepsLeft(), + })} +

+ {this.bisecting() ? ( +
+ +

{app.translator.trans('core.admin.advanced.maintenance.bisect_modal.issue_question_help')}

+
+ ) : null} + {this.bisecting() ? ( + <> +
+ + +
+ + + ) : ( + + )} +
+
+ ); + } + + 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(); + }); + } +} diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx index 092b96270f..2e362e9699 100644 --- a/framework/core/js/src/common/Application.tsx +++ b/framework/core/js/src/common/Application.tsx @@ -138,6 +138,7 @@ export interface ApplicationData { resources: SavedModelData[]; session: { userId: number; csrfToken: string }; maintenanceMode?: MaintenanceMode; + bisecting?: boolean; [key: string]: unknown; } diff --git a/framework/core/js/src/common/components/Modal.tsx b/framework/core/js/src/common/components/Modal.tsx index 7b1787798f..d5c345a460 100644 --- a/framework/core/js/src/common/components/Modal.tsx +++ b/framework/core/js/src/common/components/Modal.tsx @@ -165,7 +165,7 @@ export default abstract class Modal + {app.translator.trans('core.lib.notices.bisecting_continue')} + , + ]} + > + {app.translator.trans('core.lib.notices.bisecting')} + , + 90 + ); + } + if (app.data.maintenanceMode) { items.add( 'maintenanceMode', {app.translator.trans('core.lib.notices.maintenance_mode_' + app.data.maintenanceMode)} - + , + 80 ); } diff --git a/framework/core/less/admin.less b/framework/core/less/admin.less index 1d2f885b66..5134e067f9 100644 --- a/framework/core/less/admin.less +++ b/framework/core/less/admin.less @@ -2,6 +2,7 @@ @import "admin/AdminHeader"; @import "admin/AdminNav"; +@import "admin/AdvancedPage"; @import "admin/CreateUserModal"; @import "admin/DashboardPage"; @import "admin/AlertWidget"; diff --git a/framework/core/less/admin/AdvancedPage.less b/framework/core/less/admin/AdvancedPage.less new file mode 100644 index 0000000000..e19e04dcd0 --- /dev/null +++ b/framework/core/less/admin/AdvancedPage.less @@ -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; +} diff --git a/framework/core/less/common/Button.less b/framework/core/less/common/Button.less index b299db63b0..eb92c9fa4b 100644 --- a/framework/core/less/common/Button.less +++ b/framework/core/less/common/Button.less @@ -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; diff --git a/framework/core/less/common/root.less b/framework/core/less/common/root.less index 0fad22d8eb..3ddf7d58e1 100644 --- a/framework/core/less/common/root.less +++ b/framework/core/less/common/root.less @@ -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'); diff --git a/framework/core/less/common/variables.less b/framework/core/less/common/variables.less index c381877b70..4995e850a4 100644 --- a/framework/core/less/common/variables.less +++ b/framework/core/less/common/variables.less @@ -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%); diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index f632dad530..7f540a16f0 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -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 config.php file is overriding these settings. help: You can still change these settings here, but they will not take effect until you set offline to 0 in your config.php 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. diff --git a/framework/core/src/Api/Controller/ExtensionBisectController.php b/framework/core/src/Api/Controller/ExtensionBisectController.php new file mode 100644 index 0000000000..ec66954744 --- /dev/null +++ b/framework/core/src/Api/Controller/ExtensionBisectController.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/framework/core/src/Api/routes.php b/framework/core/src/Api/routes.php index 80baf9cd75..7a7dc412d1 100644 --- a/framework/core/src/Api/routes.php +++ b/framework/core/src/Api/routes.php @@ -307,6 +307,13 @@ $route->toController(Controller\ShowExtensionReadmeController::class) ); + // Extension bisect + $map->post( + '/extension-bisect', + 'extension-bisect', + $route->toController(Controller\ExtensionBisectController::class) + ); + // Update settings $map->post( '/settings', diff --git a/framework/core/src/Console/ConsoleServiceProvider.php b/framework/core/src/Console/ConsoleServiceProvider.php index 7f60c38dcd..a9ab9aadb9 100644 --- a/framework/core/src/Console/ConsoleServiceProvider.php +++ b/framework/core/src/Console/ConsoleServiceProvider.php @@ -13,6 +13,7 @@ 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 @@ public function register(): void 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 ]; diff --git a/framework/core/src/Extension/Bisect.php b/framework/core/src/Extension/Bisect.php new file mode 100644 index 0000000000..a817fc9296 --- /dev/null +++ b/framework/core/src/Extension/Bisect.php @@ -0,0 +1,150 @@ +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()); + } +} diff --git a/framework/core/src/Extension/BisectState.php b/framework/core/src/Extension/BisectState.php new file mode 100644 index 0000000000..aa438bb02d --- /dev/null +++ b/framework/core/src/Extension/BisectState.php @@ -0,0 +1,99 @@ +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; + } +} diff --git a/framework/core/src/Extension/Console/BisectCommand.php b/framework/core/src/Extension/Console/BisectCommand.php new file mode 100644 index 0000000000..5596b5385e --- /dev/null +++ b/framework/core/src/Extension/Console/BisectCommand.php @@ -0,0 +1,66 @@ +output->writeln('Starting bisect...'); + + $start = true; + + $result = $this->bisect->checkIssueUsing(function (array $step) use (&$start) { + if (! $start) { + $this->output->writeln("Continuing bisect... {$step['stepsLeft']} steps left"); + $this->output->writeln('Issue is in one of: ('.implode(', ', $step['relevantEnabled']).') or ('.implode(', ', $step['relevantDisabled']).')'); + } else { + $start = false; + } + + return $this->output->confirm('Does the issue still occur?'); + })->run(); + + if (! $result) { + $this->output->writeln('Could not find the extension causing the issue.'); + + 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("Extension causing the issue: $title"); + } +} diff --git a/framework/core/src/Extension/ExtensionServiceProvider.php b/framework/core/src/Extension/ExtensionServiceProvider.php index 4aee718b26..7a6eab251a 100644 --- a/framework/core/src/Extension/ExtensionServiceProvider.php +++ b/framework/core/src/Extension/ExtensionServiceProvider.php @@ -11,6 +11,7 @@ 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 @@ public function register(): void }); } - public function boot(Dispatcher $events): void + public function boot(Dispatcher $events, SettingsRepositoryInterface $settings): void { + BisectState::setSettings($settings); + $events->listen( Disabling::class, DefaultLanguagePackGuard::class diff --git a/framework/core/src/Frontend/Content/CorePayload.php b/framework/core/src/Frontend/Content/CorePayload.php index b622e39428..ca72aee386 100644 --- a/framework/core/src/Frontend/Content/CorePayload.php +++ b/framework/core/src/Frontend/Content/CorePayload.php @@ -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 @@ private function buildPayload(Document $document, Request $request): array $payload['maintenanceMode'] = $this->maintenance->mode(); } + if ($this->settings->get(BisectState::SETTING)) { + $payload['bisecting'] = true; + } + return $payload; }