057-filament-v5-upgrade (#66)

Summary: Upgrade Filament to v5 (Livewire v4), replace Filament v4-only plugins, add first-party JSON renderer, and harden Monitoring/Ops UX guardrails.
What I changed:
Composer: upgraded filament/filament → v5, removed pepperfm/filament-json and lara-zeus/torch-filament, added torchlight/engine.
Views: replaced JSON viewer with json-viewer.blade.php and updated snapshot display.
Tests: added DB-only + tenant-isolation guard tests under Monitoring and OpsUx, plus Filament smoke tests.
Specs: added/updated specs/057-filament-v5-upgrade/* (spec, tasks, plan, quickstart, research).
Formatting: ran Pint; ran full test suite (641 passed, 5 skipped).
Validation:
Ran ./vendor/bin/sail artisan test (full suite) — all tests passed.
Ran ./vendor/bin/sail pint --dirty — formatting applied.
Ran npm run build locally (Vite) — assets generated.
Notes / Rollback:
Rollback: revert composer.json/composer.lock and build assets; documented in quickstart.md.
One pending app migration was noted during validation; ensure migrations are applied in staging before deploy.
Reviewers: @frontend, @backend (adjust as needed)
Spec links:
spec.md
tasks.md
quickstart.md

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #66
This commit is contained in:
ahmido 2026-01-20 21:19:27 +00:00
parent a97beefda3
commit 971105daa9
49 changed files with 1565 additions and 656 deletions

View File

@ -24,8 +24,6 @@ class BackupItemsRelationManager extends RelationManager
{
protected static string $relationship = 'items';
public ?int $pollUntil = null;
protected $listeners = [
'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
];
@ -33,21 +31,12 @@ class BackupItemsRelationManager extends RelationManager
public function closeAddPoliciesModal(): void
{
$this->unmountAction();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}
public function shouldPollTable(): bool
{
return $this->pollUntil !== null && now()->getTimestamp() < $this->pollUntil;
}
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->poll(fn (): ?string => (filled($this->mountedActions) || ! $this->shouldPollTable()) ? null : '2s')
->columns([
Tables\Columns\TextColumn::make('policy.display_name')
->label('Item')
@ -120,6 +109,12 @@ public function table(Table $table): Table
])
->filters([])
->headerActions([
Actions\Action::make('refreshTable')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$this->resetTable();
}),
Actions\Action::make('addPolicies')
->label('Add Policies')
->icon('heroicon-o-plus')
@ -218,10 +213,6 @@ public function table(Table $table): Table
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}),
])->icon('heroicon-o-ellipsis-vertical'),
])
@ -312,10 +303,6 @@ public function table(Table $table): Table
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}),
]),
]);

View File

@ -325,8 +325,6 @@ public function table(Table $table): Table
])
->send();
$this->resetTable();
$this->dispatch('backup-set-policy-picker:close')
->to(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class);
}),

View File

@ -38,6 +38,10 @@ public function panel(Panel $panel): Panel
->colors([
'primary' => Color::Amber,
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()
)
->renderHook(
PanelsRenderHook::BODY_END,
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)

View File

@ -7,11 +7,10 @@
"license": "MIT",
"require": {
"php": "^8.2",
"filament/filament": "^4.0",
"lara-zeus/torch-filament": "^2.0",
"filament/filament": "^5.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"pepperfm/filament-json": "^4"
"torchlight/engine": "^0.1.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.16",

712
composer.lock generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.interceptMessage(({message:e,onSuccess:i})=>{i(()=>{this.$nextTick(()=>{e.component.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(i=>{let t=i.querySelector("input[type=checkbox]");t.disabled||t.checked!==e&&(t.checked=e,t.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function r({initialHeight:i,shouldAutosize:s,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),s?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=i+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let e=this.$el.style.height;this.$el.style.height="0px";let t=this.$el.scrollHeight+"px";this.$el.style.height=e,this.wrapperEl.style.height!==t&&(this.wrapperEl.style.height=t)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:g,livewireId:m,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);g&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.interceptMessage(({message:n,onSuccess:o})=>{o(()=>{this.$nextTick(()=>{if(n.component.id!==m)return;let l=this.getTabs();l.includes(this.tab)||(this.tab=l[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,o,l,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let d=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),s=Math.ceil(d.clientWidth),r=a?Math.ceil(a.clientWidth):0;return{label:s,badge:r,total:s+(r>0?o+r:0)}});for(let i=0;i<t.length;i++){let d=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,s=p.slice(i+1),r=s.length>0,W=r?Math.max(...s.map(b=>b.total)):0,D=r?l+W+o+h+n:0;if(d+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!g)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),o=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let l=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),d=this.findOverflowIndex(n,l,h,p,i,u);n.forEach((a,s)=>a.style.display=o[s]),d!==-1&&(this.withinDropdownIndex=d),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};

View File

@ -1 +1 @@
(()=>{var d=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let t=this.$el.parentElement;t&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(t),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let t=this.$el.parentElement;if(!t)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=t.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var u=function(t,e,n){let i=t;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)i=i.includes(".")?i.slice(0,i.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(i)?e:["",null,void 0].includes(e)?i:`${i}.${e}`},c=t=>{let e=Alpine.findClosest(t,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:t})=>({handleFormValidationError(e){e.detail.livewireId===t&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let i=n;for(;i;)i.dispatchEvent(new CustomEvent("expand")),i=i.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:t,containerPath:e,$wire:n})=>({$statePath:t,$get:(i,s)=>n.$get(u(e,i,s)),$set:(i,s,a,o=!1)=>n.$set(u(e,i,a),s,o),get $state(){return n.$get(t)}})),window.Alpine.data("filamentActionsSchemaComponent",d),Livewire.hook("commit",({component:t,commit:e,respond:n,succeed:i,fail:s})=>{i(({snapshot:a,effects:o})=>{o.dispatches?.forEach(r=>{if(!r.params?.awaitSchemaComponent)return;let l=Array.from(t.el.querySelectorAll(`[wire\\:partial="schema-component::${r.params.awaitSchemaComponent}"]`)).filter(h=>c(h)===t);if(l.length!==1){if(l.length>1)throw`Multiple schema components found with key [${r.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${t.id}-${r.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(r.name,{detail:r.params}))},{once:!0})}})})})});})();
(()=>{var o=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let i=this.$el.parentElement;i&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(i),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let i=this.$el.parentElement;if(!i)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=i.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var a=function(i,e,n){let t=i;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)t=t.includes(".")?t.slice(0,t.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(t)?e:["",null,void 0].includes(e)?t:`${t}.${e}`},d=i=>{let e=Alpine.findClosest(i,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:i})=>({handleFormValidationError(e){e.detail.livewireId===i&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let t=n;for(;t;)t.dispatchEvent(new CustomEvent("expand")),t=t.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:i,containerPath:e,$wire:n})=>({$statePath:i,$get:(t,r)=>n.$get(a(e,t,r)),$set:(t,r,s,l=!1)=>n.$set(a(e,t,s),r,l),get $state(){return n.$get(i)}})),window.Alpine.data("filamentActionsSchemaComponent",o),Livewire.interceptMessage(({message:i,onSuccess:e})=>{e(({payload:n})=>{n.effects?.dispatches?.forEach(t=>{if(!t.params?.awaitSchemaComponent)return;let r=Array.from(i.component.el.querySelectorAll(`[wire\\:partial="schema-component::${t.params.awaitSchemaComponent}"]`)).filter(s=>d(s)===i.component);if(r.length!==1){if(r.length>1)throw`Multiple schema components found with key [${t.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${component.id}-${t.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(t.name,{detail:t.params}))},{once:!0})}})})})});})();

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default};
function a({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,init(){Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let r=this.getServerState();r===void 0||Alpine.raw(this.state)===r||(this.state=r)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(i,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{a as default};

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:d,respond:u})=>{n(({snapshot:f,effect:h})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e}}}export{o as default};
function a({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,init(){Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let r=this.getServerState();r===void 0||this.getNormalizedState()===r||(this.state=r)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(i,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e}}}export{a as default};

View File

@ -1 +1 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default};
function a({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,init(){Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let r=this.getServerState();r===void 0||Alpine.raw(this.state)===r||(this.state=r)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(i,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{a as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,71 @@
(() => {
// TenantPilot shim: ensure Livewire interceptMessage callbacks run in a safe order.
// This prevents Filament's notifications asset (and others) from calling an undefined
// animation callback when onSuccess fires before onFinish.
if (typeof window === 'undefined') {
return;
}
const Livewire = window.Livewire;
if (!Livewire || typeof Livewire.interceptMessage !== 'function') {
return;
}
if (Livewire.__tenantpilotInterceptMessageShimApplied) {
return;
}
const original = Livewire.interceptMessage.bind(Livewire);
Livewire.interceptMessage = (handler) => {
if (typeof handler !== 'function') {
return original(handler);
}
return original((context) => {
if (!context || typeof context !== 'object') {
return handler(context);
}
const originalOnFinish = context.onFinish;
const originalOnSuccess = context.onSuccess;
if (typeof originalOnFinish !== 'function' || typeof originalOnSuccess !== 'function') {
return handler(context);
}
const finishCallbacks = [];
const onFinish = (callback) => {
if (typeof callback === 'function') {
finishCallbacks.push(callback);
}
return originalOnFinish(callback);
};
const onSuccess = (callback) => {
return originalOnSuccess((...args) => {
// Ensure any registered finish callbacks are run before success callbacks.
// We don't swallow errors; we just stabilize ordering.
for (const finishCallback of finishCallbacks) {
finishCallback(...args);
}
if (typeof callback === 'function') {
return callback(...args);
}
});
};
return handler({
...context,
onFinish,
onSuccess,
});
});
};
Livewire.__tenantpilotInterceptMessageShimApplied = true;
})();

View File

@ -159,6 +159,12 @@
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
@if (! empty($row['path']) && ($row['label'] ?? null) !== ($row['path'] ?? null))
<p class="mt-0.5 text-xs font-mono text-gray-400 dark:text-gray-500 break-all">
{{ (string) $row['path'] }}
</p>
@endif
@if(!empty($row['description']))
<p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p>
@endif

View File

@ -1,68 +1,5 @@
@php
// Obtain state from Filament infolist entry
$payload = $getState();
// Normalize payload to array for the JSON viewer
$payloadArray = is_string($payload) ? (json_decode($payload, true) ?? []) : ($payload ?? []);
$rawJson = json_encode($payloadArray, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// Provide the small set of helpers the pepperfm json view expects
$getState = fn () => $payloadArray;
$getCharacterLimit = fn () => null;
$getAsModal = fn () => false;
$getAsDrawer = fn () => false;
$getKeyColumnLabel = fn () => 'Key';
$getValueColumnLabel = fn () => 'Value';
$getButtonConfig = fn () => (object) ['id' => 'fj-modal', 'icon' => null, 'iconColor' => null, 'alignment' => null, 'width' => null, 'closeByClickingAway' => true, 'closedByEscaping' => true, 'closedButton' => true, 'label' => null, 'tooltip' => null, 'size' => null, 'href' => null, 'tag' => null, 'color' => null];
$getModalConfig = fn () => (object) ['id' => 'fj-modal', 'icon' => null, 'iconColor' => null, 'alignment' => null, 'width' => null, 'closeByClickingAway' => true, 'closedByEscaping' => true, 'closedButton' => true];
$getRenderMode = fn () => \PepperFM\FilamentJson\Enums\RenderModeEnum::Tree;
$getInitiallyCollapsed = fn () => 1;
$getExpandAllToggle = fn () => false;
$getCopyJsonAction = fn () => false;
$getMaxDepth = fn () => 3;
$applyLimit = fn ($v) => $v;
@endphp
<div
class="space-y-2"
x-data="{
text: @js($rawJson),
async copyJson() {
try {
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
await navigator.clipboard.writeText(this.text);
} else {
const ta = document.createElement('textarea');
ta.value = this.text;
ta.style.position = 'fixed';
ta.style.inset = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
ta.remove();
}
new FilamentNotification()
.title('Copied!')
.icon('heroicon-o-clipboard-document-check')
.success()
.send();
} catch (e) {
new FilamentNotification()
.title('Copy failed!')
.danger()
.send();
}
}
}"
>
<div class="flex items-center justify-end">
<x-filament::button size="xs" color="gray" x-on:click="copyJson()">
Copy JSON
</x-filament::button>
</div>
{{-- Render pepperfm filament-json viewer --}}
@include('filament-json::json')
</div>
@include('filament.partials.json-viewer', ['value' => $payload])

View File

@ -1,3 +1,5 @@
<div class="space-y-4">
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" />
<livewire:backup-set-policy-picker-table
:backupSetId="$backupSetId"
/>
</div>

View File

@ -0,0 +1,61 @@
@php
/** @var mixed $value */
$value = $value ?? null;
$payloadArray = is_string($value)
? (json_decode($value, true) ?? [])
: ($value ?? []);
$rawJson = json_encode(
$payloadArray,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
) ?: '';
@endphp
<div
class="space-y-2"
x-data="{
text: @js($rawJson),
copied: false,
async copyJson() {
try {
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
await navigator.clipboard.writeText(this.text);
} else {
const ta = document.createElement('textarea');
ta.value = this.text;
ta.style.position = 'fixed';
ta.style.inset = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
ta.remove();
}
this.copied = true;
setTimeout(() => (this.copied = false), 1500);
} catch (e) {
this.copied = false;
}
},
}"
>
<div class="flex items-center justify-end gap-2">
<span
x-show="copied"
x-transition
class="text-sm text-gray-500 dark:text-gray-400"
>
Copied
</span>
<x-filament::button size="xs" color="gray" x-on:click="copyJson()">
Copy JSON
</x-filament::button>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900">
<pre class="max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-900 dark:text-gray-100" x-text="text"></pre>
</div>
</div>

View File

@ -0,0 +1 @@
<script defer src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>

View File

@ -58,6 +58,7 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
timer: null,
activeSinceMs: null,
fastUntilMs: null,
teardownObserver: null,
init() {
this.onVisibilityChange = this.onVisibilityChange.bind(this);
@ -66,6 +67,16 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
this.onNavigated = this.onNavigated.bind(this);
window.addEventListener('livewire:navigated', this.onNavigated);
// Ensure we always detach listeners when Livewire/Filament removes this
// element (for example during modal unmounts or navigation/morph).
this.teardownObserver = new MutationObserver(() => {
if (!this.$el || this.$el.isConnected !== true) {
this.destroy();
}
});
this.teardownObserver.observe(document.body, { childList: true, subtree: true });
// First sync immediately.
this.schedule(0);
},
@ -74,6 +85,11 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
this.stop();
window.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('livewire:navigated', this.onNavigated);
if (this.teardownObserver) {
this.teardownObserver.disconnect();
this.teardownObserver = null;
}
},
stop() {
@ -164,7 +180,25 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
return;
}
try {
await this.$wire.refreshRuns();
} catch (error) {
// Livewire will reject pending action promises when requests are
// cancelled (for example during `wire:navigate`). That's expected
// for this background poller, so we swallow cancellation rejections.
const isCancellation = Boolean(
error &&
typeof error === 'object' &&
error.status === null &&
error.body === null &&
error.json === null &&
error.errors === null,
);
if (!isCancellation) {
console.warn('Ops UX widget refreshRuns failed', error);
}
}
const next = this.nextIntervalMs();
if (next === null) {
@ -181,7 +215,7 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
const delay = Math.max(0, Number(delayMs ?? 0));
this.timer = setTimeout(() => {
this.tick();
this.tick().catch(() => {});
}, delay);
},
};

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Admin UI Stack Upgrade (Panel + Suite)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-20
**Feature**: [specs/057-filament-v5-upgrade/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This is a technical upgrade, but the spec intentionally describes outcomes and guardrails rather than implementation steps.
- Proceed to planning: identify concrete packages, versions, and migration steps in plan/tasks.

View File

@ -0,0 +1,18 @@
# Contracts (API / Graph)
This feature is a UI framework upgrade (Filament v5 + Livewire v4).
## External HTTP / API Contracts
- No new application API endpoints are introduced.
- No OpenAPI / GraphQL contract changes are required.
## Microsoft Graph
- No new Microsoft Graph calls or behaviors are introduced.
- Any existing Graph usage remains behind `GraphClientInterface` and the contract registry in `config/graph_contracts.php`.
## Monitoring DB-only Guardrail
- Monitoring → Operations UI (including widgets/partials/tabs) remains DB-only during render and background Livewire requests.
- “Remote call” is defined as any outbound HTTP request.

View File

@ -0,0 +1,43 @@
# Data Model: Admin UI Stack Upgrade (Filament v5 + Livewire v4)
This feature is a framework/UI dependency upgrade. No new domain entities are introduced.
## Existing Entities (Referenced)
### Tenant
- Purpose: tenant-scopes all UI data access.
- Key fields (existing): `id`, tenant identity metadata.
- Relationships: `Tenant` → many `OperationRun`.
### OperationRun
- Purpose: canonical Monitoring → Operations record per constitution.
- Key fields (existing, inferred from usage):
- `tenant_id`
- `type`, `status`, `outcome`
- `initiator_name`
- `created_at`, `started_at`, `completed_at`
- JSONB: run context/failures/summary (per repo context)
- Relationships:
- belongsTo `Tenant`.
## Schema / Migration Expectations
### Decision: No database schema changes intended
- Rationale:
- Filament/Livewire upgrade does not require domain schema changes.
- Spec allows migrations only if strictly required by dependencies; current plan avoids introducing such requirements.
### Guardrails (if a migration becomes necessary)
- Must be reversible with a safe `down()`.
- Must be non-destructive (no data loss; avoid drops unless explicitly planned).
- Must be called out in release notes.
## Validation Rules / State Transitions
- No new validation rules.
- No new state machines.
- Existing `OperationRun` status/outcome conventions remain unchanged.

View File

@ -0,0 +1,125 @@
#+#+#+#+markdown
# Implementation Plan: Admin UI Stack Upgrade (Filament v5 + Livewire v4)
**Branch**: `057-filament-v5-upgrade` | **Date**: 2026-01-20 | **Spec**: `specs/057-filament-v5-upgrade/spec.md`
**Input**: Feature specification from `specs/057-filament-v5-upgrade/spec.md`
## Summary
Upgrade the admin UI stack from Filament v4 to Filament v5 (and Livewire v4 as a required dependency), while preserving TenantPilots constitution guardrails:
- Monitoring → Operations pages (including widgets/partials/tabs) remain DB-only during render and during background Livewire requests (polling/auto-refresh/hydration): **no outbound HTTP**.
- Tenant isolation remains mandatory across all UI interactions.
- No new Microsoft Graph behavior is introduced.
Key compatibility decision: two Composer dependencies are Filament v4-only and will block the upgrade:
- `pepperfm/filament-json:^4` (requires `filament/filament:^4.0`)
- `lara-zeus/torch-filament:^2` (requires `filament/filament:^4.0`)
The plan replaces their in-app usage with first-party Blade-based renderers (Torchlight Engine already present) so we can move the stack forward without mixed-version pinning.
## Technical Context
**Language/Version**: PHP 8.4.15 (project requires `php:^8.2`)
**Framework**: Laravel 12
**Admin UI**: Filament v4 → v5
**Reactive UI**: Livewire v3 → v4 (required by Filament v5)
**Frontend Tooling**: Vite 7, Tailwind CSS v4 (already in use), `@tailwindcss/vite`
**Storage**: PostgreSQL (operations tracked via `operation_runs` and JSONB columns per repo history)
**Testing**: Pest (Feature + Unit), PHPUnit 12 runner via `php artisan test`
**Target Platform**: Docker/Sail-first locally; Dokploy for staging/prod
**Constraints**:
- Monitoring → Operations: DB-only render and background Livewire requests; no outbound HTTP.
- All Graph calls stay behind `GraphClientInterface` + `config/graph_contracts.php`.
- No new Graph endpoints/behavior introduced by this upgrade.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / Snapshots-second: unaffected by UI framework upgrade.
- Read/write separation: unchanged; no new writes introduced.
- Graph contract path: unchanged; the upgrade must not add new Graph calls.
- Deterministic capabilities: unchanged.
- Tenant isolation: must be preserved; audit all Filament/Livewire touchpoints that depend on tenant context.
- Run observability: unchanged; ensure Monitoring pages remain DB-only at render time.
- Automation / idempotency: unchanged.
- Data minimization / safe logging: unchanged.
**Gate status (pre-research)**: PASS (no justified violations required).
## Project Structure
### Documentation (this feature)
```text
specs/057-filament-v5-upgrade/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks) - NOT created here
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Monitoring/Operations.php
│ └── Resources/
├── Livewire/
│ └── BulkOperationProgress.php
├── Models/
│ └── OperationRun.php
└── Providers/
└── Filament/AdminPanelProvider.php
resources/
├── views/
│ ├── filament/
│ │ ├── pages/monitoring/operations.blade.php
│ │ ├── infolists/entries/snapshot-json.blade.php
│ │ └── infolists/entries/normalized-diff.blade.php
│ └── livewire/bulk-operation-progress.blade.php
└── css/filament/admin/theme.css
```
**Structure Decision**: single Laravel web application; no new top-level folders required.
## Phase 0 — Research (output: research.md)
See `specs/057-filament-v5-upgrade/research.md` for:
- Version compatibility matrix (Filament v5 requires PHP ^8.2; Livewire v4 supports Laravel 12).
- Third-party blockers and the replacement approach (drop Filament-v4-only plugins; keep Torchlight Engine for code rendering; implement a small in-app JSON renderer).
- Livewire v4 migration hotspots in this repo (polling + `livewire:navigated` behavior, `wire:model.live` usage).
## Phase 1 — Design (outputs: data-model.md, contracts/*, quickstart.md)
- Data model: no intended schema changes; validate no new migrations are required by Filament/Livewire upgrades.
- Contracts: no new HTTP/API contracts; explicitly confirm “no Graph changes”.
- Quickstart: Sail-first local validation plus minimal smoke checks for Monitoring DB-only behavior.
**Gate status (post-design)**: PASS (no new Graph calls; Monitoring remains DB-only by design).
## Phase 2 — Implementation Outline (NOT executed by /speckit.plan)
1. Dependency upgrade
- Update Composer constraints: `filament/filament:^5`, ensure Livewire resolves to `^4`.
- Remove/replace Filament v4-only plugins (`pepperfm/filament-json`, `lara-zeus/torch-filament`) and update in-app rendering.
2. UI migration + compatibility
- Run Filament upgrade commands as required by Filament v5.
- Fix breakages in custom panel/provider config (`app/Providers/Filament/AdminPanelProvider.php`).
- Validate Livewire event and polling behavior (global Ops UX widget).
3. Guardrail verification
- Monitoring → Operations pages: verify no outbound HTTP happens during render/poll requests.
- Tenant switching: verify UI state and Livewire component data remains tenant-scoped.
4. Tests
- Add/update targeted Pest tests for:
- Monitoring pages render without HTTP calls (use HTTP fake + assertions).
- Ops UX progress widget remains DB-only and tenant-scoped.
- Run minimal suite locally (Sail-first), then broaden as needed.

View File

@ -0,0 +1,104 @@
# Quickstart: Admin UI Stack Upgrade (Filament v5 + Livewire v4)
This quickstart is for validating the upgrade locally (Sail-first) and performing focused smoke checks.
## Prerequisites
- Docker + Docker Compose (for Sail)
- Node.js + npm
## Baseline (pre-upgrade)
Record these versions before changing dependencies so rollback is straightforward:
- Composer (from composer.json):
- `filament/filament`: `^4.0`
- `livewire/livewire`: transitive (will become `^4` when upgrading to Filament v5)
- `pepperfm/filament-json`: `^4`
- `lara-zeus/torch-filament`: `^2.0`
- Frontend (from package.json):
- `vite`: `^7.0.7`
- `tailwindcss`: `^4.0.0`
- `@tailwindcss/vite`: `^4.0.0`
## Local Setup (Sail-first)
From repo root:
- Start containers: `./vendor/bin/sail up -d`
- Install PHP dependencies: `./vendor/bin/sail composer install`
- Install JS dependencies: `npm install`
- Run migrations: `./vendor/bin/sail artisan migrate`
- Build assets: `npm run build` (or `npm run dev` while iterating)
## Focused Validation Checklist
### 1) Admin UI smoke
- Sign in.
- Load the Filament dashboard.
- Open at least one resource list/table and one resource form, and save a non-destructive edit.
### 2) Monitoring → Operations guardrails (DB-only)
Goal: everything under Monitoring → Operations must stay DB-only during render and during background Livewire requests.
Manual smoke:
- Open Monitoring → Operations page.
- Leave the page open while:
- a global widget polls (Ops UX progress widget), and
- Livewire components hydrate.
- Verify no remote HTTP calls are triggered by rendering or background Livewire requests.
Implementation verification guidance (for the later implementation phase):
- Add a targeted Pest feature test that uses `Http::fake()` and asserts no requests were sent while rendering Monitoring pages.
### 3) Tenant isolation sanity
- Switch tenant using the apps tenancy switcher.
- Re-open Monitoring → Operations.
- Verify all visible runs are scoped to the selected tenant.
## Tests
Run the minimum relevant tests first:
- Single file: `./vendor/bin/sail artisan test tests/Feature/...`
- Filter: `./vendor/bin/sail artisan test --filter=...`
## Formatting
Before finalizing implementation:
- `./vendor/bin/sail php ./vendor/bin/pint --dirty`
## Common Issues
## Migrations note
- The Filament v5 / Livewire v4 upgrade does not introduce new vendor-required migrations.
- If you see pending app migrations (e.g. `2026_01_18_000001_drop_bulk_operation_runs_table`), apply them as usual with `./vendor/bin/sail artisan migrate`.
- Vite manifest errors after upgrading dependencies: run `npm run build` (or `npm run dev`) and refresh.
- Browser caching after deploy: hard refresh / clear cache to pick up new Filament/Livewire assets.
## Rollback (fast path)
If the Filament v5 / Livewire v4 upgrade needs to be reverted in a hurry:
1) Revert dependency files to a known-good commit (or restore the pre-upgrade versions):
- `composer.json` + `composer.lock`
- `package.json` + lockfile
2) Re-install dependencies and rebuild assets:
- `./vendor/bin/sail composer install`
- `npm install`
- `npm run build`
3) Clear caches (especially if a removed package/provider was involved):
- `./vendor/bin/sail artisan optimize:clear`
4) If Filament assets/config were published in the upgraded state, re-publish for the rolled-back version (only if needed):
- `./vendor/bin/sail artisan vendor:publish --tag=filament-assets --force`

View File

@ -0,0 +1,124 @@
# Research: Admin UI Stack Upgrade (Filament v5 + Livewire v4)
This document resolves planning unknowns for `057-filament-v5-upgrade`.
## Version Compatibility
### Decision: Upgrade to Filament v5 and Livewire v4
- Decision: Upgrade `filament/filament` to `^5` and let Composer resolve `livewire/livewire:^4` as required.
- Rationale:
- Filament v5 is the next major and current supported release.
- Livewire v4 supports Laravel 12 and PHP >= 8.1 (project is PHP 8.4.15).
- Keeps the admin UI stack aligned with upstream support/security.
- Alternatives considered:
- Stay on Filament v4: rejected (explicit goal is v5 upgrade).
- Attempt mixed-version pinning (Filament v4 plugins + Filament v5 core): rejected by spec (no mixed-version pinning).
### Evidence (Packagist)
- `filament/filament v5.0.0` requires `php:^8.2`.
- `livewire/livewire v4.x` requires `laravel/framework:^10.15|^11|^12` (Laravel 12 compatible).
## Third-Party Package Compatibility
### Decision: Remove Filament v4-only plugins and replace functionality in-app
Two Composer requirements are currently hard-pinned to Filament v4:
- `pepperfm/filament-json:^4` -> requires `filament/filament:^4.0`
- `lara-zeus/torch-filament:^2` -> requires `filament/filament:^4.0`
Because Filament v5 cannot satisfy these constraints, we must remove/replace them.
#### `pepperfm/filament-json`
- Observed in-app usage:
- `resources/views/filament/infolists/entries/snapshot-json.blade.php` includes `filament-json::json` and sets helper closures expected by the package.
- Decision:
- Replace the JSON viewer infolist entry with a first-party Blade renderer.
- Rationale:
- The app already normalizes the JSON payload and provides a "Copy JSON" action.
- A first-party renderer avoids blocking Filament v5.
- We can reuse Torchlight Engine for syntax-highlighted JSON rendering if desired.
- Alternatives considered:
- Wait for upstream Filament v5 support: rejected (would block the upgrade).
- Find another third-party Filament v5 JSON plugin: possible fallback, but first-party renderer is lowest-risk/no dependency.
#### `lara-zeus/torch-filament`
- Observed in-app usage:
- No direct usage found in `app/**` or `resources/**`.
- Torchlight rendering is implemented directly using `torchlight/engine` in Blade views (e.g. `normalized-diff` entry).
- Decision:
- Remove the package and keep `torchlight/engine` usage directly.
- Rationale:
- Removes a Filament v4-only dependency.
- Keeps the "code highlighting" feature via the engine the app already uses.
## Livewire v4 Migration Risk Areas
### Decision: Treat Livewire navigation/polling widget behavior as a primary regression surface
Observed hotspots:
- `resources/views/livewire/bulk-operation-progress.blade.php`
- Uses `wire:poll.5s="refreshRuns"`.
- Uses a JS poller that listens for `livewire:navigated` and calls `$wire.refreshRuns()`.
- `resources/views/livewire/backup-set-policy-picker-table.blade.php`
- Uses `wire:model.live`.
Rationale:
- Livewire major upgrades commonly change lifecycle events and JS hook APIs.
- Ops UX widget is global and runs across navigation; breakage would be highly visible.
Alternatives considered:
- "Upgrade and hope": rejected; we should plan explicit regression checks.
## Monitoring DB-only Guardrail Implications
### Decision: Monitoring -> Operations must remain DB-only across all Livewire requests
- Definition: "remote call" means any outbound HTTP request.
- Applies to:
- Initial render
- Background Livewire requests (polling/auto-refresh/hydration)
- Implementation implication:
- Any Livewire component used under Monitoring -> Operations must not call Graph/HTTP in lifecycle hooks.
- Remote work must remain "enqueue-only" behind explicit user actions and `OperationRun` tracking.
Alternatives considered:
- Allowing background remote calls for freshness: rejected by spec/constitution.
## Proposed Implementation Sequencing
1. Upgrade core dependencies (Filament v5 -> Livewire v4).
2. Remove/replace Filament v4-only plugins:
- Replace snapshot JSON view
- Remove torch-filament (retain torchlight/engine)
3. Run Filament upgrade command(s) and fix panel/provider issues.
4. Fix Livewire v4 regressions in polling + navigation event handling.
5. Add/adjust targeted tests and verify Monitoring DB-only behavior.
## Implementation Outcome (Final)
### Dependency decisions (as implemented)
- Filament upgraded to v5; Livewire upgraded to v4 (resolved transitively).
- Removed Filament v4-only dependencies:
- `pepperfm/filament-json`
- `lara-zeus/torch-filament`
- Kept code highlighting by explicitly requiring `torchlight/engine` (and its syntax highlighter dependency `phiki/phiki`).
### Guardrails validated
- Monitoring -> Operations remains DB-only during initial render and during background Livewire requests (no outbound HTTP).
- Monitoring -> Operations remains tenant-scoped; cross-tenant access is forbidden.
- Monitoring render does not enqueue background work; enqueue remains behind explicit user actions.
### Validation notes
- Full suite gate `php artisan test` run after upgrade.

View File

@ -0,0 +1,110 @@
# Feature Specification: Admin UI Stack Upgrade (Panel + Suite)
**Feature Branch**: `057-filament-v5-upgrade`
**Created**: 2026-01-20
**Status**: Draft
**Input**: Upgrade the existing admin UI stack to the next supported major release to maintain compatibility and support, and ensure no regressions for tenant isolation and Ops-UX/Monitoring guardrails.
## Clarifications
### Session 2026-01-20
- Q: What exactly counts as a “remote call” that is forbidden during Monitoring/Operations page render? → A: Any outbound HTTP request.
- Q: Are background/automatic Livewire requests (polling, auto-refresh, hydration) allowed to make outbound HTTP calls on Monitoring/Operations pages? → A: No; Monitoring/Operations must remain DB-only even for polling/auto-refresh/hydration. Remote work is only allowed via explicit user actions that enqueue tracked operations.
- Q: Which pages count as “Monitoring/Operations” for the DB-only rule? → A: Everything rendered under the Monitoring → Operations navigation section, including all widgets/partials/tabs rendered within that section.
- Q: If a third-party package is incompatible with the upgraded stack, what remediation is allowed? → A: Upgrade/replace the package to preserve functionality. Mixed-version pinning is not allowed. If something is not realistically replaceable, treat it as an explicit scope/decision change.
- Q: Do we expect this upgrade to require any database schema/data changes? → A: Allowed only if strictly required by dependencies; must be reversible, non-destructive, backward compatible, and called out in release notes.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Admin UI keeps working after upgrade (Priority: P1)
Administrators can sign in and use the admin panel (navigation, resources, forms, tables, actions) without runtime errors after the upgrade.
**Why this priority**: This is the minimum bar for the upgrade to be safe; if the admin UI is unstable, all operational work stops.
**Independent Test**: A smoke run that signs in, loads the dashboard, opens at least one resource list + form, and executes a non-destructive action without errors.
**Acceptance Scenarios**:
1. **Given** a valid admin account, **When** they sign in and navigate through the panel, **Then** pages load without server exceptions or browser console errors.
2. **Given** an existing resource record, **When** the admin edits and saves it, **Then** the save succeeds and feedback (toast/notification) is shown.
---
### User Story 2 - Monitoring & Ops UX remain safe (Priority: P2)
Operators can use Monitoring/Operations pages and related widgets without triggering any remote calls during page rendering, and without cross-tenant leakage.
**Why this priority**: Monitoring must remain predictable and safe (DB-only at render), and tenant isolation is a core security guarantee.
**Independent Test**: Load Monitoring pages for a tenant with existing runs; verify the page renders and interactivity works while meeting the “DB-only render time” rule.
**Acceptance Scenarios**:
1. **Given** an operator viewing Monitoring for a tenant, **When** the page renders, **Then** it completes without performing any remote calls during render and without queuing background work.
2. **Given** an operator switches tenants, **When** they navigate to Monitoring again, **Then** all data shown is scoped to the active tenant.
---
### User Story 3 - Deployment remains reliable (Priority: P3)
Developers and operators can build frontend assets and deploy the upgraded application without regressions, and can roll back quickly if needed.
**Why this priority**: A framework upgrade is only useful if it can be safely deployed and reversed during incidents.
**Independent Test**: Build assets and boot the application from a clean checkout using the projects standard local workflow.
**Acceptance Scenarios**:
1. **Given** a clean checkout, **When** dependencies are installed and assets are built, **Then** the build completes without errors.
2. **Given** a deployment with the upgraded stack, **When** operators need to revert, **Then** a documented rollback procedure can restore the previous working release.
### Edge Cases
- A third-party admin/Live UI package is incompatible with the upgraded stack.
- Users have stale browser assets after deploy and experience partial UI breakage.
- SPA navigation and global widgets fail to mount or miss events after navigation.
- Tenant switching occurs mid-session and cached UI state risks showing stale cross-tenant data.
- Monitoring pages accidentally perform remote calls during render (must be prevented/detected).
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature is an upgrade. It MUST NOT introduce new Microsoft Graph calls or new Graph write behavior.
All content rendered under the Monitoring → Operations navigation section remains “DB-only” and must not perform any outbound HTTP requests during render or during background/automatic Livewire requests (polling/auto-refresh/hydration). Tenant isolation remains mandatory.
### Functional Requirements
- **FR-001**: The application MUST upgrade the admin panel stack to the next supported major release and its required compatible reactive UI layer.
- **FR-002**: The application MUST continue to support the existing styling system without asset build failures.
- **FR-003**: All existing Filament panels MUST load successfully for authorized users and preserve core interactions (navigation, tables, forms, actions, notifications, widgets).
- **FR-004**: SPA-style navigation flows MUST continue to work, including global widget mounting and event delivery across navigation.
- **FR-005**: Everything rendered under the Monitoring → Operations navigation section (including widgets/partials/tabs) MUST remain DB-only: no outbound HTTP requests are permitted during page render or during background/automatic Livewire requests (polling/auto-refresh/hydration).
- **FR-010**: Any remote work initiated from Monitoring/Operations pages MUST be triggered only by explicit user actions (e.g., buttons) and MUST enqueue tracked operations (e.g., `OperationRun`-backed jobs) rather than performing outbound HTTP inline.
- **FR-006**: Tenant isolation MUST be preserved across requests and Live UI interactions: all reads/writes/events/caches MUST scope to the active tenant.
- **FR-007**: Compatibility risks MUST be managed by producing an explicit inventory of affected third-party packages and documenting upgrade/replacement decisions.
- **FR-011**: If a third-party package is incompatible with the upgraded stack, the system MUST preserve equivalent functionality by upgrading or replacing the package; mixed-version pinning is not allowed. Any unavoidable feature loss MUST be handled as an explicit scope/decision change.
- **FR-012**: Database migrations are allowed only if strictly required for compatibility; they MUST be reversible and non-destructive (no data loss). Migrations MUST include a safe `down()` path and avoid drops without an explicit plan, and MUST be mentioned in release notes.
- **FR-008**: The upgrade MUST not introduce new Microsoft Graph read/write behavior; if any Graph-touching behavior changes are required, they MUST be explicitly specified with safety gates and observability updates.
- **FR-009**: The upgrade MUST include a documented rollback procedure that restores the previous working state.
### Assumptions & Dependencies
- The current platform runtime versions already meet (or exceed) the minimum requirements for the next major admin UI stack release.
- Third-party UI packages used in the admin panel may need upgrades or replacements to remain compatible.
- Existing tenant isolation and “DB-only render time” guard tests (or equivalent checks) exist and remain authoritative for this upgrade.
### Key Entities *(include if feature involves data)*
- **Tenant**: The active tenant context that scopes all data access and UI state.
- **OperationRun**: The persisted record of long-running operations shown in Monitoring/Operations views.
- **AuditLog**: The tenant-scoped audit trail used to retain accountability for sensitive actions.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An administrator can sign in and reach a usable dashboard within 60 seconds on a fresh deployment.
- **SC-002**: In regression testing, Monitoring/Operations pages render without any remote calls during render in 100% of runs.
- **SC-003**: 0 critical UI-blocking errors occur in the primary admin journeys (P1 + P2 scenarios) during manual QA.
- **SC-004**: Automated regression checks pass (100% green) for the projects standard test run.

View File

@ -0,0 +1,176 @@
---
description: "Task list for feature implementation"
---
# Tasks: Admin UI Stack Upgrade (Filament v5 + Livewire v4)
**Input**: Design documents from `specs/057-filament-v5-upgrade/`
**Notes**
- Tests are REQUIRED for runtime behavior changes (Pest) per repo standards.
- Monitoring → Operations must remain DB-only during render and background Livewire requests (no outbound HTTP).
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare for a safe upgrade with fast feedback.
- [X] T001 Confirm guardrails + story priorities in specs/057-filament-v5-upgrade/spec.md
- [X] T002 [P] Record current Composer state for rollback planning in composer.json and composer.lock
- [X] T003 [P] Record current frontend build state for rollback planning in package.json and package-lock.json (or npm lockfile)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared test/validation scaffolding used by multiple stories.
- [X] T004 Create HTTP-noise guard helper in tests/Support/AssertsNoOutboundHttp.php
- [X] T005 [P] Add a Pest expectation/wrapper for HTTP guard in tests/Pest.php
- [X] T006 [P] Add a minimal “Filament panel boots” smoke test skeleton in tests/Feature/Filament/FilamentBootsTest.php
**Checkpoint**: Foundational validation utilities exist; story work can begin.
---
## Phase 3: User Story 1 — Admin UI keeps working after upgrade (Priority: P1) 🎯 MVP
**Goal**: Admins can sign in and use the Filament panel post-upgrade without runtime errors.
**Independent Test**: `php artisan test --filter=Filament` passes; manual smoke sign-in + open resource list + open/edit/save.
### Tests for User Story 1
- [X] T007 [P] [US1] Implement Filament boot smoke test assertions in tests/Feature/Filament/FilamentBootsTest.php
- [X] T008 [P] [US1] Add a basic auth+navigation smoke test in tests/Feature/Filament/AdminSmokeTest.php
### Implementation for User Story 1
- [X] T009 [US1] Upgrade Filament to v5 in composer.json and composer.lock
- [X] T010 [US1] Remove Filament v4-only packages in composer.json and composer.lock (pepperfm/filament-json, lara-zeus/torch-filament)
- [X] T011 [US1] Discover and run required Filament upgrade command(s) (verify via `php artisan list` / Filament upgrade docs), then reconcile any generated config/view changes; completion = panel boots (T007) + auth/navigation smoke (T008) pass
- [X] T011A [US1] Verify dependency upgrade does not introduce new required migrations; if it does, ensure reversibility + document in quickstart rollback/release notes
- [X] T012 [P] [US1] Replace pepperfm JSON viewer usage in resources/views/filament/infolists/entries/snapshot-json.blade.php (remove @include('filament-json::json'))
- [X] T013 [P] [US1] Introduce a first-party JSON renderer partial in resources/views/filament/partials/json-viewer.blade.php
- [X] T014 [US1] Wire snapshot JSON infolist entry to the first-party renderer in resources/views/filament/infolists/entries/snapshot-json.blade.php
- [X] T015 [US1] Remove any Torch Filament-specific usage and ensure Torchlight Engine rendering still works in resources/views/filament/infolists/entries/normalized-diff.blade.php
- [X] T016 [US1] Update Filament panel/provider config for v5 compatibility in app/Providers/Filament/AdminPanelProvider.php
**Checkpoint**: Filament panel boots and core admin journeys work.
---
## Phase 4: User Story 2 — Monitoring & Ops UX remain safe (Priority: P2)
**Goal**: Monitoring → Operations remains tenant-scoped and DB-only (no outbound HTTP during render or background Livewire requests).
**Independent Test**: Monitoring page renders with `Http::fake()` and asserts nothing was sent; tenant switching keeps data scoped.
### Tests for User Story 2
- [X] T017 [P] [US2] Add Monitoring DB-only render test in tests/Feature/Monitoring/OperationsDbOnlyTest.php
- [X] T018 [P] [US2] Add tenant isolation test for Monitoring query scoping in tests/Feature/Monitoring/OperationsTenantScopeTest.php
- [X] T019 [P] [US2] Add Ops UX progress component DB-only test in tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php
- [X] T019A [P] [US2] Add Monitoring render side-effects test in tests/Feature/Monitoring/OperationsActionsEnqueueRunTest.php (assert no background work is enqueued during render; no outbound HTTP)
### Implementation for User Story 2
- [X] T020 [US2] Verify Monitoring Operations table remains tenant-scoped (OperationRunResource query scoping + legacy Operations page) in app/Filament/Resources/OperationRunResource.php and app/Filament/Pages/Monitoring/Operations.php
- [X] T020A [US2] Audit Monitoring/Ops page actions to ensure they only authorize + enqueue work (no outbound HTTP inline) and always link to an OperationRun
- [X] T021 [US2] Ensure Operations views remain DB-only and have no side-effects (OperationRunResource + legacy page view)
- [X] T022 [US2] Audit Ops UX progress Livewire component for DB-only behavior and tenant scoping in app/Livewire/BulkOperationProgress.php
- [X] T023 [US2] Update Livewire navigation event handling for v4 (if needed) in resources/views/livewire/bulk-operation-progress.blade.php
- [X] T024 [US2] Validate polling strategy remains safe and doesnt trigger outbound HTTP from Monitoring pages in resources/views/livewire/bulk-operation-progress.blade.php
- [X] T025 [US2] Validate any `wire:model.live` usage works correctly under Livewire v4 in resources/views/livewire/backup-set-policy-picker-table.blade.php
**Checkpoint**: Monitoring → Operations is DB-only, tenant-safe, and interactive.
---
## Phase 5: User Story 3 — Deployment remains reliable (Priority: P3)
**Goal**: Build/deploy succeeds and rollback is documented.
**Independent Test**: Clean install + `npm run build` + `php artisan test` succeeds.
- [X] T026 [US3] Verify Vite inputs and Filament theme build still work under v5 in vite.config.js and resources/css/filament/admin/theme.css
- [X] T027 [US3] Ensure frontend deps remain compatible (Tailwind v4/Vite) in package.json
- [X] T028 [US3] Document rollback steps (Composer + assets + any required migrations) in specs/057-filament-v5-upgrade/quickstart.md
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T029 [P] Run formatting for touched files with ./vendor/bin/pint --dirty (constitution gate)
- [X] T030 Run targeted test suites for this feature (Filament/Monitoring/OpsUx) via tests/Feature/*
- [X] T031 Run full test suite gate via php artisan test (phpunit.xml)
- [X] T032 [P] Update specs/057-filament-v5-upgrade/research.md with final dependency decisions and any deviations
### Post-merge hotfixes
- [X] T033 Fix Filament Notifications JS Livewire hook ordering (prevent `TypeError: e is not a function`)
- [X] T034 Add regression test for notifications asset hook usage
- [X] T035 Swallow Livewire cancellation promise rejections in Ops UX widget poller
- [X] T036 Ensure Ops UX widget Alpine poller cleans up on unmount (avoid stale listeners / refresh loops)
- [X] T037 Fix Backup Items modal refresh loop race condition (remove BackupItems relation manager polling; reduce unnecessary refresh churn)
- [X] T038 Add regression test ensuring Backup Items table does not use Livewire polling
---
## Dependencies & Execution Order
### User Story Dependencies (graph)
- US1 (P1) → unblocks general UI usability checks
- US2 (P2) → depends on the Filament/Livewire upgrade from US1 being in place
- US3 (P3) → depends on US1 (dependency upgrades) and validates deploy/rollback
Recommended order: **Foundational → US1 → US2 → US3 → Polish**
---
## Parallel Execution Examples
### User Story 1
Can run in parallel (example):
```text
Run together: T012 + T013
Also in flight: T009 + T010
```
### User Story 2
Can run in parallel (example):
```text
Draft tests: T017 + T018 + T019
Implement: T020 through T025
```
### User Story 3
Can run in parallel (example):
```text
Run together: T026 + T028
```
---
## Implementation Strategy
### MVP Scope
MVP is **User Story 1 (P1)**: Filament v5 + Livewire v4 upgrade with the panel booting and core admin flows working.
### Incremental Delivery
1) Land Foundational helpers/tests scaffolding
2) Land US1 (upgrade + plugin removals/replacements)
3) Land US2 (Monitoring DB-only + tenant isolation protections)
4) Land US3 (build/deploy + rollback)

View File

@ -0,0 +1,12 @@
<?php
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('authenticated tenant member can load a tenant-scoped Filament page', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user)
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->assertOk()
->assertSee($tenant->name);
});

View File

@ -0,0 +1,30 @@
<?php
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Models\BackupItem;
use App\Models\BackupSet;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('does not poll the backup items table (prevents modal refresh loops)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
]);
BackupItem::factory()->for($backupSet)->for($tenant)->create();
Livewire::test(BackupItemsRelationManager::class, [
'ownerRecord' => $backupSet,
'pageClass' => EditBackupSet::class,
])
->assertDontSee('wire:poll');
});

View File

@ -0,0 +1,11 @@
<?php
it('serves the Filament admin login page', function () {
$this->get('/admin/login')
->assertOk();
});
it('redirects unauthenticated users to the Filament login', function () {
$this->get('/admin')
->assertRedirectContains('/admin/login');
});

View File

@ -0,0 +1,17 @@
<?php
it('ships a Filament notifications asset compatible with Livewire v4 message hook order', function () {
$js = file_get_contents(public_path('js/filament/notifications/notifications.js'));
$shim = file_get_contents(public_path('js/tenantpilot/livewire-intercept-shim.js'));
expect($js)
->toContain('onFinish')
->and($js)->toContain('onSuccess')
->and($js)->not->toContain('onRender');
expect($shim)
->toContain('TenantPilot shim')
->and($shim)->toContain('Livewire.interceptMessage')
->and($shim)->toContain('__tenantpilotInterceptMessageShimApplied');
});

View File

@ -0,0 +1,9 @@
<?php
it('loads the Livewire intercept shim on the Filament login page', function () {
$response = $this->get('/admin/login');
$response
->assertSuccessful()
->assertSee('js/tenantpilot/livewire-intercept-shim.js', escape: false);
});

View File

@ -0,0 +1,31 @@
<?php
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
it('does not enqueue work while rendering Monitoring → Operations', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
$this->actingAs($user);
Bus::fake();
Queue::fake();
assertNoOutboundHttp(function () use ($tenant) {
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
->assertOk();
});
Bus::assertNothingDispatched();
Queue::assertNothingPushed();
});

View File

@ -0,0 +1,52 @@
<?php
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use Illuminate\Support\Facades\Bus;
it('renders Monitoring → Operations index DB-only (no outbound HTTP, no background work)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
$this->actingAs($user);
Bus::fake();
assertNoOutboundHttp(function () use ($tenant) {
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
->assertOk();
});
Bus::assertNothingDispatched();
});
it('renders Monitoring → Operations detail DB-only (no outbound HTTP, no background work)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
$this->actingAs($user);
Bus::fake();
assertNoOutboundHttp(function () use ($tenant, $run) {
$this->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
->assertOk()
->assertSee('Policy sync');
});
Bus::assertNothingDispatched();
});

View File

@ -0,0 +1,63 @@
<?php
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\Tenant;
it('scopes Monitoring → Operations list to the active tenant', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
[$user] = createUserWithTenant($tenantA, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
OperationRun::factory()->create([
'tenant_id' => $tenantA->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'TenantA',
]);
OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'TenantB',
]);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenantA))
->assertOk()
->assertSee('Policy sync')
->assertSee('TenantA')
->assertDontSee('Inventory sync')
->assertDontSee('TenantB');
});
it('prevents cross-tenant access to Monitoring → Operations detail', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
[$user] = createUserWithTenant($tenantA, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$runB = OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'TenantB',
]);
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
->assertNotFound();
});

View File

@ -0,0 +1,53 @@
<?php
use App\Livewire\BulkOperationProgress;
use App\Models\OperationRun;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps the Ops UX progress widget DB-only (no outbound HTTP) and tenant-scoped', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
[$user] = createUserWithTenant($tenantA, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
OperationRun::factory()->create([
'tenant_id' => $tenantA->getKey(),
'type' => 'policy.sync',
'status' => 'running',
'outcome' => 'pending',
'initiator_name' => 'TenantA',
]);
OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'type' => 'inventory.sync',
'status' => 'running',
'outcome' => 'pending',
'initiator_name' => 'TenantB',
]);
$this->actingAs($user);
Filament::setTenant($tenantA, true);
assertNoOutboundHttp(function () {
Livewire::test(BulkOperationProgress::class)
->call('refreshRuns')
->assertSet('disabled', false)
->assertSee('Policy sync')
->assertDontSee('Inventory sync');
});
})->group('ops-ux');
it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {
$contents = file_get_contents(resource_path('views/livewire/bulk-operation-progress.blade.php'));
expect($contents)->toContain('new MutationObserver');
expect($contents)->toContain('teardownObserver');
})->group('ops-ux');

View File

@ -4,6 +4,7 @@
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Support\AssertsNoOutboundHttp;
use Tests\Support\FailHardGraphClient;
/*
@ -73,6 +74,11 @@ function bindFailHardGraphClient(): void
app()->instance(GraphClientInterface::class, new FailHardGraphClient);
}
function assertNoOutboundHttp(Closure $callback): mixed
{
return AssertsNoOutboundHttp::run($callback);
}
/**
* @return array{0: User, 1: Tenant}
*/

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use Closure;
use Illuminate\Support\Facades\Http;
final class AssertsNoOutboundHttp
{
/**
* @template TReturn
*
* @param Closure(): TReturn $callback
* @return TReturn
*/
public static function run(Closure $callback): mixed
{
Http::fake();
try {
return $callback();
} finally {
Http::assertNothingSent();
}
}
}