feat: polish tenant dashboard operations attention UX #338
@ -43,6 +43,7 @@ protected function getViewData(): array
|
||||
'recommendedActions' => [],
|
||||
'governanceStatus' => [],
|
||||
'readinessCards' => [],
|
||||
'activeOperationSummary' => null,
|
||||
'recentOperations' => [],
|
||||
'pollingInterval' => null,
|
||||
];
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
* @param list<array<string, mixed>> $recommendedActions
|
||||
* @param list<array<string, mixed>> $governanceStatus
|
||||
* @param list<array<string, mixed>> $readinessCards
|
||||
* @param array<string, mixed>|null $activeOperationSummary
|
||||
* @param list<array<string, mixed>> $recentOperations
|
||||
*/
|
||||
public function __construct(
|
||||
@ -22,6 +23,7 @@ public function __construct(
|
||||
public array $recommendedActions,
|
||||
public array $governanceStatus,
|
||||
public array $readinessCards,
|
||||
public ?array $activeOperationSummary,
|
||||
public array $recentOperations,
|
||||
public ?string $pollingInterval,
|
||||
) {}
|
||||
@ -34,6 +36,7 @@ public function __construct(
|
||||
* recommendedActions: list<array<string, mixed>>,
|
||||
* governanceStatus: list<array<string, mixed>>,
|
||||
* readinessCards: list<array<string, mixed>>,
|
||||
* activeOperationSummary: array<string, mixed>|null,
|
||||
* recentOperations: list<array<string, mixed>>,
|
||||
* pollingInterval: ?string,
|
||||
* }
|
||||
@ -47,6 +50,7 @@ public function toArray(): array
|
||||
'recommendedActions' => $this->recommendedActions,
|
||||
'governanceStatus' => $this->governanceStatus,
|
||||
'readinessCards' => $this->readinessCards,
|
||||
'activeOperationSummary' => $this->activeOperationSummary,
|
||||
'recentOperations' => $this->recentOperations,
|
||||
'pollingInterval' => $this->pollingInterval,
|
||||
];
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -42,6 +43,7 @@
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class TenantDashboardSummaryBuilder
|
||||
@ -142,6 +144,7 @@ public function build(ManagedEnvironment $tenant, ?User $user = null): TenantDas
|
||||
latestEvidenceSnapshot: $latestEvidenceSnapshot,
|
||||
exceptionStats: $exceptionStats,
|
||||
),
|
||||
activeOperationSummary: $this->activeOperationSummary($tenant, $user),
|
||||
recentOperations: $this->recentOperationCards($tenant, $recentOperations),
|
||||
pollingInterval: ActiveRuns::pollingIntervalForTenant($tenant),
|
||||
);
|
||||
@ -370,12 +373,7 @@ private function kpis(ManagedEnvironment $tenant, ?User $user, TenantGovernanceA
|
||||
$highSeverityChart = $this->highSeverityFindingsChart($tenant);
|
||||
$operationsFollowUpChart = $this->operationsFollowUpChart($tenant);
|
||||
|
||||
$operationsNeedingFollowUp = (int) OperationRun::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where(function ($query): void {
|
||||
$query->terminalFollowUp()->orWhere(fn ($inner) => $inner->activeStaleAttention());
|
||||
})
|
||||
->count();
|
||||
$operationsNeedingFollowUp = (int) $this->operationsRequiringAttentionQuery($tenant)->count();
|
||||
|
||||
return [
|
||||
$this->metricCard(
|
||||
@ -424,7 +422,7 @@ private function kpis(ManagedEnvironment $tenant, ?User $user, TenantGovernanceA
|
||||
action: $this->operationsAction(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
label: $this->overviewText('action_view_all_operations'),
|
||||
label: $this->overviewText('action_open_operations_hub'),
|
||||
activeTab: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : 'active',
|
||||
problemClass: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : null,
|
||||
),
|
||||
@ -497,13 +495,8 @@ private function activeOperationsKpiDescription(int $count, ?array $chart): stri
|
||||
return $this->overviewText('kpi_active_operations_tendency_none');
|
||||
}
|
||||
|
||||
$windowCount = $chart === null ? 0 : array_sum($chart);
|
||||
|
||||
if ($windowCount > 0) {
|
||||
return $this->overviewText('kpi_active_operations_tendency_window', [
|
||||
'count' => $count,
|
||||
'window' => $windowCount,
|
||||
]);
|
||||
if ($count === 1) {
|
||||
return $this->overviewText('kpi_active_operations_tendency_one');
|
||||
}
|
||||
|
||||
return $this->overviewText('kpi_active_operations_tendency', ['count' => $count]);
|
||||
@ -548,9 +541,7 @@ private function highSeverityFindingsChart(ManagedEnvironment $tenant): ?array
|
||||
private function operationsFollowUpChart(ManagedEnvironment $tenant): ?array
|
||||
{
|
||||
$window = $this->sevenDayWindow();
|
||||
$byDay = OperationRun::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->dashboardNeedsFollowUp()
|
||||
$byDay = $this->operationsRequiringAttentionQuery($tenant)
|
||||
->where(function (Builder $query) use ($window): void {
|
||||
$query
|
||||
->whereBetween('completed_at', [$window['start'], $window['end']])
|
||||
@ -628,21 +619,21 @@ private function recommendedActions(
|
||||
$candidates[] = $this->actionCandidate(
|
||||
priority: 10,
|
||||
key: 'required_permissions',
|
||||
title: $this->overviewText('action_open_required_permissions'),
|
||||
title: $this->overviewText('action_review_permissions'),
|
||||
reason: $this->overviewText('reason_missing_application_permissions', ['count' => $missingApplicationPermissions]),
|
||||
impact: $this->overviewText('impact_missing_application_permissions'),
|
||||
tone: 'danger',
|
||||
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
||||
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_review_permissions')),
|
||||
);
|
||||
} elseif ($missingDelegatedPermissions > 0) {
|
||||
$candidates[] = $this->actionCandidate(
|
||||
priority: 20,
|
||||
key: 'delegated_permissions',
|
||||
title: $this->overviewText('action_open_required_permissions'),
|
||||
title: $this->overviewText('action_review_permissions'),
|
||||
reason: $this->overviewText('reason_missing_delegated_permissions', ['count' => $missingDelegatedPermissions]),
|
||||
impact: $this->overviewText('impact_missing_delegated_permissions'),
|
||||
tone: 'warning',
|
||||
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
||||
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_review_permissions')),
|
||||
);
|
||||
}
|
||||
|
||||
@ -701,25 +692,24 @@ private function recommendedActions(
|
||||
);
|
||||
}
|
||||
|
||||
$terminalFollowUpRuns = (int) OperationRun::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->terminalFollowUp()
|
||||
->count();
|
||||
$operationsRequiringAttention = $this->operationsRequiringAttentionRuns($tenant);
|
||||
|
||||
if ($operationsRequiringAttention->isNotEmpty()) {
|
||||
$dominantProblemClass = $this->dominantAttentionProblemClass($operationsRequiringAttention);
|
||||
|
||||
if ($terminalFollowUpRuns > 0) {
|
||||
$candidates[] = $this->actionCandidate(
|
||||
priority: 70,
|
||||
key: 'terminal_operations',
|
||||
title: $this->overviewText('action_view_all_operations'),
|
||||
reason: $this->overviewText('reason_terminal_operations', ['count' => $terminalFollowUpRuns]),
|
||||
impact: $this->overviewText('impact_terminal_operations'),
|
||||
priority: 35,
|
||||
key: 'operations_requiring_attention',
|
||||
title: $this->overviewText('action_review_operations_requiring_attention'),
|
||||
reason: $this->overviewText('reason_operations_requiring_attention'),
|
||||
impact: $this->overviewText('impact_operations_requiring_attention'),
|
||||
tone: 'danger',
|
||||
action: $this->operationsAction(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
label: $this->overviewText('action_view_all_operations'),
|
||||
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
label: $this->overviewText('action_review_operations'),
|
||||
activeTab: $dominantProblemClass,
|
||||
problemClass: $dominantProblemClass,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -993,6 +983,68 @@ private function recentOperationCards(ManagedEnvironment $tenant, array $recentO
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function activeOperationSummary(ManagedEnvironment $tenant, ?User $user): ?array
|
||||
{
|
||||
if (! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$qualifyingRuns = $this->operationsRequiringAttentionRuns($tenant);
|
||||
|
||||
if ($qualifyingRuns->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dominantProblemClass = $this->dominantAttentionProblemClass($qualifyingRuns);
|
||||
|
||||
return [
|
||||
'title' => $this->overviewText('operations_attention_title'),
|
||||
'count' => $qualifyingRuns->count(),
|
||||
'tone' => 'warning',
|
||||
'secondaryActionLabel' => $this->overviewText('action_open_operations_hub'),
|
||||
'secondaryActionUrl' => OperationRunLinks::index(
|
||||
$tenant,
|
||||
activeTab: $dominantProblemClass,
|
||||
problemClass: $dominantProblemClass,
|
||||
),
|
||||
'items' => $this->attentionOperationItems($qualifyingRuns, $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
private function compareActiveOperationSummaryRuns(OperationRun $left, OperationRun $right): int
|
||||
{
|
||||
$priorityComparison = $this->activeOperationSummaryPriority($left) <=> $this->activeOperationSummaryPriority($right);
|
||||
|
||||
if ($priorityComparison !== 0) {
|
||||
return $priorityComparison;
|
||||
}
|
||||
|
||||
$timestampComparison = $this->activeOperationSummaryTimestamp($right) <=> $this->activeOperationSummaryTimestamp($left);
|
||||
|
||||
if ($timestampComparison !== 0) {
|
||||
return $timestampComparison;
|
||||
}
|
||||
|
||||
return ((int) $right->getKey()) <=> ((int) $left->getKey());
|
||||
}
|
||||
|
||||
private function activeOperationSummaryPriority(OperationRun $run): int
|
||||
{
|
||||
return match ($run->problemClass()) {
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 0,
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 1,
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
|
||||
private function activeOperationSummaryTimestamp(OperationRun $run): int
|
||||
{
|
||||
return ($run->completed_at ?? $run->started_at ?? $run->created_at)?->getTimestamp() ?? 0;
|
||||
}
|
||||
|
||||
private function governanceStatusIcon(string $key): string
|
||||
{
|
||||
return match ($key) {
|
||||
@ -1067,7 +1119,7 @@ private function recommendedActionIcon(string $key): string
|
||||
return match ($key) {
|
||||
'required_permissions', 'delegated_permissions', 'high_severity_findings' => 'heroicon-m-shield-exclamation',
|
||||
'overdue_findings' => 'heroicon-o-clock',
|
||||
'recovery_posture', 'terminal_operations', 'continue_review' => 'heroicon-o-arrow-path-rounded-square',
|
||||
'recovery_posture', 'operations_requiring_attention', 'continue_review' => 'heroicon-o-arrow-path-rounded-square',
|
||||
'risk_exceptions' => 'heroicon-o-exclamation-triangle',
|
||||
default => 'heroicon-o-exclamation-triangle',
|
||||
};
|
||||
@ -1220,6 +1272,170 @@ private function operationsAction(ManagedEnvironment $tenant, ?User $user, strin
|
||||
);
|
||||
}
|
||||
|
||||
private function operationsRequiringAttentionQuery(ManagedEnvironment $tenant): Builder
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->dashboardNeedsFollowUp();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, OperationRun>
|
||||
*/
|
||||
private function operationsRequiringAttentionRuns(ManagedEnvironment $tenant): Collection
|
||||
{
|
||||
return $this->operationsRequiringAttentionQuery($tenant)
|
||||
->get()
|
||||
->sort(fn (OperationRun $left, OperationRun $right): int => $this->compareActiveOperationSummaryRuns($left, $right))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, OperationRun> $runs
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function attentionOperationItems(Collection $runs, ManagedEnvironment $tenant): array
|
||||
{
|
||||
return $runs
|
||||
->take(3)
|
||||
->map(function (OperationRun $run) use ($tenant): array {
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $run->status,
|
||||
'freshness_state' => $run->freshnessState()->value,
|
||||
]);
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $run->outcome,
|
||||
'status' => (string) $run->status,
|
||||
'freshness_state' => $run->freshnessState()->value,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => (int) $run->getKey(),
|
||||
'identifier' => OperationRunLinks::identifier($run),
|
||||
'type' => OperationCatalog::label((string) $run->type),
|
||||
'title' => $this->attentionOperationTitle($run),
|
||||
'icon' => $this->recentOperationIcon((string) $run->type),
|
||||
'attentionLabel' => $this->attentionOperationBadgeLabel($run),
|
||||
'problemClass' => $run->problemClass(),
|
||||
'problemClassLabel' => OperationUxPresenter::problemClassLabel($run),
|
||||
'statusLabel' => $statusSpec->label,
|
||||
'statusTone' => $statusSpec->color,
|
||||
'outcomeLabel' => $outcomeSpec->label,
|
||||
'outcomeTone' => $outcomeSpec->color,
|
||||
'outcomeSentence' => $this->attentionOperationOutcomeSentence($run),
|
||||
'reason' => $this->attentionOperationReason($run),
|
||||
'impact' => $this->attentionOperationImpact($run),
|
||||
'timingLabel' => $this->attentionOperationTimingLabel($run),
|
||||
'createdAt' => $run->completed_at?->diffForHumans() ?? $run->created_at?->diffForHumans(),
|
||||
'primaryActionLabel' => $this->overviewText('action_review_operation'),
|
||||
'primaryActionUrl' => OperationRunLinks::view($run, $tenant),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, OperationRun> $runs
|
||||
*/
|
||||
private function dominantAttentionProblemClass(Collection $runs): string
|
||||
{
|
||||
return $runs->contains(fn (OperationRun $run): bool => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||
? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||
: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
|
||||
}
|
||||
|
||||
private function attentionOperationTitle(OperationRun $run): string
|
||||
{
|
||||
return OperationCatalog::label((string) $run->type);
|
||||
}
|
||||
|
||||
private function attentionOperationBadgeLabel(OperationRun $run): string
|
||||
{
|
||||
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
|
||||
? $this->overviewText('operations_attention_badge_stale')
|
||||
: $this->overviewText('operations_attention_badge_follow_up');
|
||||
}
|
||||
|
||||
private function attentionOperationOutcomeSentence(OperationRun $run): string
|
||||
{
|
||||
if ($run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION) {
|
||||
return $this->overviewText('operations_attention_outcome_stale');
|
||||
}
|
||||
|
||||
if ($this->isProviderConsentBlockedRun($run)) {
|
||||
return $this->overviewText('operations_attention_outcome_provider_consent_required');
|
||||
}
|
||||
|
||||
return match ((string) $run->outcome) {
|
||||
OperationRunOutcome::Blocked->value => $this->overviewText('operations_attention_outcome_blocked'),
|
||||
OperationRunOutcome::PartiallySucceeded->value => $this->overviewText('operations_attention_outcome_partial'),
|
||||
OperationRunOutcome::Failed->value => $this->overviewText('operations_attention_outcome_failed'),
|
||||
default => $this->overviewText('operations_attention_outcome_generic'),
|
||||
};
|
||||
}
|
||||
|
||||
private function attentionOperationReason(OperationRun $run): string
|
||||
{
|
||||
if ($this->isProviderConsentBlockedRun($run)) {
|
||||
return $this->overviewText('operations_attention_reason_provider_consent_required');
|
||||
}
|
||||
|
||||
$operatorExplanation = OperationUxPresenter::governanceOperatorExplanation($run);
|
||||
$reason = trim((string) ($operatorExplanation?->dominantCauseExplanation ?? ''));
|
||||
|
||||
if ($reason !== '') {
|
||||
return $reason;
|
||||
}
|
||||
|
||||
$failureDetail = trim((string) (OperationUxPresenter::surfaceFailureDetail($run) ?? ''));
|
||||
|
||||
if ($failureDetail !== '') {
|
||||
return $failureDetail;
|
||||
}
|
||||
|
||||
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
|
||||
? $this->overviewText('operations_attention_reason_stale')
|
||||
: $this->overviewText('operations_attention_reason_fallback');
|
||||
}
|
||||
|
||||
private function attentionOperationImpact(OperationRun $run): string
|
||||
{
|
||||
if ($this->isProviderConsentBlockedRun($run)) {
|
||||
return $this->overviewText('operations_attention_impact_provider_consent_required');
|
||||
}
|
||||
|
||||
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
|
||||
? $this->overviewText('operations_attention_impact_stale')
|
||||
: $this->overviewText('operations_attention_impact_follow_up');
|
||||
}
|
||||
|
||||
private function attentionOperationTimingLabel(OperationRun $run): ?string
|
||||
{
|
||||
if ($run->completed_at instanceof Carbon) {
|
||||
return $this->overviewText('operations_attention_timing_completed', [
|
||||
'time' => $run->completed_at->diffForHumans(),
|
||||
]);
|
||||
}
|
||||
|
||||
$reference = $run->started_at ?? $run->created_at;
|
||||
|
||||
if (! $reference instanceof Carbon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->overviewText('operations_attention_timing_started', [
|
||||
'time' => $reference->diffForHumans(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function isProviderConsentBlockedRun(OperationRun $run): bool
|
||||
{
|
||||
return OperationCatalog::canonicalCode((string) $run->type) === OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK
|
||||
&& (string) $run->outcome === OperationRunOutcome::Blocked->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
||||
*/
|
||||
|
||||
@ -173,17 +173,25 @@
|
||||
'kpi_missing_permissions_tendency_app_only' => ':count App-Berechtigungen fehlen',
|
||||
'kpi_missing_permissions_tendency_delegated_only' => ':count delegierte Berechtigungen fehlen',
|
||||
'kpi_missing_permissions_tendency_none' => 'Berechtigungen vollständig',
|
||||
'kpi_active_operations_label' => 'Aktive Vorgänge',
|
||||
'kpi_active_operations_description' => 'Veraltete oder terminale Vorgänge benötigen Operator-Nachverfolgung.',
|
||||
'kpi_active_operations_tendency' => ':count mit Follow-up',
|
||||
'kpi_active_operations_tendency_window' => ':count mit Follow-up · :window in 7 Tagen',
|
||||
'kpi_active_operations_tendency_none' => 'Kein Follow-up offen',
|
||||
'kpi_active_operations_label' => 'Vorgänge mit Aufmerksamkeitsbedarf',
|
||||
'kpi_active_operations_description' => 'Vorgangsläufe, die noch Follow-up benötigen, bevor der Tenant als gesund gelten kann.',
|
||||
'kpi_active_operations_tendency' => ':count Vorgänge erfordern Aufmerksamkeit',
|
||||
'kpi_active_operations_tendency_window' => ':count Vorgänge erfordern Aufmerksamkeit',
|
||||
'kpi_active_operations_tendency_one' => '1 Vorgang benötigt Follow-up',
|
||||
'kpi_active_operations_tendency_none' => 'Keine Vorgänge benötigen Aufmerksamkeit',
|
||||
'action_review_findings' => 'Findings prüfen',
|
||||
'action_open_overdue_findings' => 'Überfällige Findings öffnen',
|
||||
'action_review_permissions' => 'Berechtigungen prüfen',
|
||||
'action_open_required_permissions' => 'Erforderliche Berechtigungen öffnen',
|
||||
'action_review_risks' => 'Risiken prüfen',
|
||||
'action_review_recovery_posture' => 'Wiederherstellungsstatus prüfen',
|
||||
'action_view_all_operations' => 'Alle Vorgänge anzeigen',
|
||||
'action_view_operation' => 'Vorgang anzeigen',
|
||||
'action_review_operation' => 'Vorgang prüfen',
|
||||
'action_review_operations' => 'Vorgänge prüfen',
|
||||
'action_review_operations_requiring_attention' => 'Aufmerksamkeitspflichtige Vorgänge prüfen',
|
||||
'action_open_operations_hub' => 'Operations-Hub öffnen',
|
||||
'action_show_all_operations' => 'Alle Vorgänge anzeigen',
|
||||
'action_open_governance_inbox' => 'Governance Inbox öffnen',
|
||||
'action_continue_review' => 'Review fortsetzen',
|
||||
'action_open_baseline_compare' => 'Baseline Compare öffnen',
|
||||
@ -207,8 +215,27 @@
|
||||
'impact_recovery_posture' => 'Die Wiederherstellungsbereitschaft sollte geprüft werden, bevor kundensichere Aussagen auf Backup- oder Restore-Vertrauen beruhen.',
|
||||
'reason_terminal_operations' => ':count Vorgangslauf/Läufe endeten blockiert, teilweise oder fehlgeschlagen.',
|
||||
'impact_terminal_operations' => 'Terminale Laufergebnisse benötigen Nachverfolgung, bevor der Tenant als ruhig gelten kann.',
|
||||
'reason_operations_requiring_attention' => 'Ein oder mehrere Vorgänge endeten mit einem Ergebnis, das Follow-up benötigt.',
|
||||
'impact_operations_requiring_attention' => 'Der Tenant sollte nicht als vollständig gesund betrachtet werden, bis das Vorgangsergebnis geprüft wurde.',
|
||||
'reason_continue_review' => 'Kundensichere Ausgabe ist noch nicht vollständig bereit.',
|
||||
'impact_continue_review' => 'Die Review-Ausgabe bleibt teilweise, bis Review-, Nachweis- und Paketflächen sauber zusammenpassen.',
|
||||
'operations_attention_title' => 'Vorgänge mit Aufmerksamkeitsbedarf',
|
||||
'operations_attention_badge_follow_up' => 'Follow-up erforderlich',
|
||||
'operations_attention_badge_stale' => 'Aufmerksamkeit nötig',
|
||||
'operations_attention_outcome_blocked' => 'Der Vorgang wurde beendet, aber eine Voraussetzung hat den Abschluss blockiert.',
|
||||
'operations_attention_outcome_partial' => 'Der Vorgang wurde beendet, aber es ist weiterhin Follow-up erforderlich.',
|
||||
'operations_attention_outcome_failed' => 'Der Vorgang endete mit einem Fehler, der geprüft werden muss.',
|
||||
'operations_attention_outcome_generic' => 'Der Vorgang endete mit einem Ergebnis, das Nachverfolgung benötigt.',
|
||||
'operations_attention_outcome_stale' => 'Der Vorgang ist noch aktiv, liegt aber außerhalb seines erwarteten Lebenszyklusfensters.',
|
||||
'operations_attention_outcome_provider_consent_required' => 'Die Prüfung ist abgeschlossen, aber die Provider-Zustimmung ist noch erforderlich.',
|
||||
'operations_attention_reason_fallback' => 'Das aufgezeichnete Ergebnis muss geprüft werden, bevor der Tenant als gesund gelten kann.',
|
||||
'operations_attention_reason_stale' => 'Der Lauf liegt außerhalb seines normalen Lebenszyklusfensters und schreitet möglicherweise nicht mehr fort.',
|
||||
'operations_attention_reason_provider_consent_required' => 'Eine Admin-Zustimmung ist erforderlich, bevor die Provider-Verbindung verwendet werden kann.',
|
||||
'operations_attention_impact_follow_up' => 'Die Tenant-Bereitschaft sollte nicht als vollständig gesund betrachtet werden, bis das Vorgangsergebnis geprüft wurde.',
|
||||
'operations_attention_impact_stale' => 'Die Tenant-Bereitschaft sollte nicht als aktuell betrachtet werden, bis der blockierte Lauf geprüft wurde.',
|
||||
'operations_attention_impact_provider_consent_required' => 'Die Tenant-Bereitschaft kann nicht als gesund betrachtet werden, bis dies geprüft wurde.',
|
||||
'operations_attention_timing_completed' => 'Abgeschlossen :time',
|
||||
'operations_attention_timing_started' => 'Gestartet :time',
|
||||
'governance_baseline_compare_label' => 'Baseline Compare',
|
||||
'governance_baseline_compare_description' => 'Aktueller Compare-Status für die Tenant-Baseline.',
|
||||
'governance_evidence_coverage_label' => 'Nachweisabdeckung',
|
||||
|
||||
@ -173,17 +173,25 @@
|
||||
'kpi_missing_permissions_tendency_app_only' => ':count app missing',
|
||||
'kpi_missing_permissions_tendency_delegated_only' => ':count delegated missing',
|
||||
'kpi_missing_permissions_tendency_none' => 'Permission set complete',
|
||||
'kpi_active_operations_label' => 'Active operations',
|
||||
'kpi_active_operations_description' => 'Stale or terminal operation runs needing operator follow-up.',
|
||||
'kpi_active_operations_tendency' => ':count need follow-up',
|
||||
'kpi_active_operations_tendency_window' => ':count need follow-up · :window in 7d',
|
||||
'kpi_active_operations_tendency_none' => 'No follow-up queued',
|
||||
'kpi_active_operations_label' => 'Operations needing attention',
|
||||
'kpi_active_operations_description' => 'Operation runs that still need follow-up before the tenant can be treated as healthy.',
|
||||
'kpi_active_operations_tendency' => ':count operations require attention',
|
||||
'kpi_active_operations_tendency_window' => ':count operations require attention',
|
||||
'kpi_active_operations_tendency_one' => '1 operation needs follow-up',
|
||||
'kpi_active_operations_tendency_none' => 'No operations need attention',
|
||||
'action_review_findings' => 'Review findings',
|
||||
'action_open_overdue_findings' => 'Open overdue findings',
|
||||
'action_review_permissions' => 'Review permissions',
|
||||
'action_open_required_permissions' => 'Open required permissions',
|
||||
'action_review_risks' => 'Review risks',
|
||||
'action_review_recovery_posture' => 'Review recovery posture',
|
||||
'action_view_all_operations' => 'View all operations',
|
||||
'action_view_operation' => 'View operation',
|
||||
'action_review_operation' => 'Review operation',
|
||||
'action_review_operations' => 'Review operations',
|
||||
'action_review_operations_requiring_attention' => 'Review operations requiring attention',
|
||||
'action_open_operations_hub' => 'Open operations hub',
|
||||
'action_show_all_operations' => 'Show all operations',
|
||||
'action_open_governance_inbox' => 'Open governance inbox',
|
||||
'action_continue_review' => 'Continue review',
|
||||
'action_open_baseline_compare' => 'Open Baseline Compare',
|
||||
@ -207,8 +215,27 @@
|
||||
'impact_recovery_posture' => 'Recovery readiness should be checked before customer-safe claims rely on backup or restore confidence.',
|
||||
'reason_terminal_operations' => ':count operation run(s) finished blocked, partial, or failed.',
|
||||
'impact_terminal_operations' => 'Terminal run outcomes need follow-up before the tenant can be treated as calm.',
|
||||
'reason_operations_requiring_attention' => 'One or more operations finished with an outcome that needs follow-up.',
|
||||
'impact_operations_requiring_attention' => 'The tenant should not be treated as fully healthy until the operation outcome has been reviewed.',
|
||||
'reason_continue_review' => 'Customer-safe output is not fully ready yet.',
|
||||
'impact_continue_review' => 'Review output stays partial until the review, evidence, and pack surfaces line up cleanly.',
|
||||
'operations_attention_title' => 'Operations requiring attention',
|
||||
'operations_attention_badge_follow_up' => 'Follow-up required',
|
||||
'operations_attention_badge_stale' => 'Needs attention',
|
||||
'operations_attention_outcome_blocked' => 'The operation finished, but a prerequisite blocked completion.',
|
||||
'operations_attention_outcome_partial' => 'The operation finished, but follow-up is still required.',
|
||||
'operations_attention_outcome_failed' => 'The operation finished with a failure that needs review.',
|
||||
'operations_attention_outcome_generic' => 'The operation finished with an outcome that needs follow-up.',
|
||||
'operations_attention_outcome_stale' => 'The operation is still active, but it is past its expected lifecycle window.',
|
||||
'operations_attention_outcome_provider_consent_required' => 'The check finished, but provider consent is still required.',
|
||||
'operations_attention_reason_fallback' => 'The recorded outcome still needs operator review before the tenant can be treated as healthy.',
|
||||
'operations_attention_reason_stale' => 'The run is past its normal lifecycle window and may no longer be progressing.',
|
||||
'operations_attention_reason_provider_consent_required' => 'Admin consent is required before the provider connection can be used.',
|
||||
'operations_attention_impact_follow_up' => 'Tenant readiness should not be treated as fully healthy until the operation outcome has been reviewed.',
|
||||
'operations_attention_impact_stale' => 'Tenant readiness should not be treated as current until the stalled run has been reviewed.',
|
||||
'operations_attention_impact_provider_consent_required' => 'Tenant readiness cannot be treated as healthy until this is reviewed.',
|
||||
'operations_attention_timing_completed' => 'Completed :time',
|
||||
'operations_attention_timing_started' => 'Started :time',
|
||||
'governance_baseline_compare_label' => 'Baseline compare',
|
||||
'governance_baseline_compare_description' => 'Current compare posture for the tenant baseline.',
|
||||
'governance_evidence_coverage_label' => 'Evidence coverage',
|
||||
|
||||
@ -138,61 +138,82 @@ class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<!-- Recent Operations -->
|
||||
<x-filament::section :heading="__('localization.dashboard.overview.section_recent_operations')">
|
||||
@if ($recentOperations === [])
|
||||
<div data-testid="tenant-dashboard-recent-operations-empty" class="rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-white/10 dark:bg-white/5">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ __('localization.dashboard.overview.empty_recent_operations_headline') }}</div>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{{ __('localization.dashboard.overview.empty_recent_operations_summary') }}
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col gap-3">
|
||||
@foreach (array_slice($recentOperations, 0, 4) as $operation)
|
||||
@php
|
||||
$operationTone = match ($operation['outcomeTone']) {
|
||||
'danger' => 'border-danger-200 bg-danger-50/10 dark:border-danger-800 dark:bg-danger-500/5',
|
||||
'warning' => 'border-warning-200 bg-warning-50/10 dark:border-warning-800 dark:bg-warning-500/5',
|
||||
default => $overviewSecondaryListRowSurfaceClasses,
|
||||
};
|
||||
@endphp
|
||||
<a
|
||||
data-testid="tenant-dashboard-recent-operation"
|
||||
data-overview-row-style="secondary-list-row"
|
||||
href="{{ $operation['url'] }}"
|
||||
class="{{ $overviewSecondaryListRowBaseClasses }} {{ $overviewSecondaryListInteractiveClasses }} {{ $operationTone }}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@if (filled($operation['icon'] ?? null))
|
||||
<x-filament::icon
|
||||
data-testid="tenant-dashboard-recent-operation-icon"
|
||||
data-operation-id="{{ $operation['id'] }}"
|
||||
data-icon="{{ $operation['icon'] }}"
|
||||
:icon="$operation['icon']"
|
||||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
@if ($activeOperationSummary)
|
||||
<div
|
||||
data-testid="tenant-dashboard-operations-attention-summary"
|
||||
class="min-w-0 rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ $activeOperationSummary['title'] }}</div>
|
||||
<x-filament::badge :color="$activeOperationSummary['tone']">{{ $activeOperationSummary['count'] }}</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||
<x-filament::button
|
||||
data-testid="tenant-dashboard-operations-attention-secondary-action"
|
||||
tag="a"
|
||||
:href="$activeOperationSummary['secondaryActionUrl']"
|
||||
size="sm"
|
||||
color="gray"
|
||||
>
|
||||
{{ $activeOperationSummary['secondaryActionLabel'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
@foreach ($activeOperationSummary['items'] ?? [] as $operation)
|
||||
<div data-testid="tenant-dashboard-operations-attention-item" class="rounded-xl border border-gray-200 border-l-4 border-l-warning-400 bg-gray-50/70 p-4 dark:border-white/10 dark:border-l-warning-500 dark:bg-white/5">
|
||||
<div class="flex items-start justify-between gap-4 max-sm:flex-col max-sm:items-stretch">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (filled($operation['icon'] ?? null))
|
||||
<x-filament::icon
|
||||
data-testid="tenant-dashboard-operations-attention-item-icon"
|
||||
data-icon="{{ $operation['icon'] }}"
|
||||
:icon="$operation['icon']"
|
||||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
@endif
|
||||
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $operation['title'] }}</div>
|
||||
|
||||
@if (filled($operation['attentionLabel'] ?? null))
|
||||
<x-filament::badge color="warning">{{ $operation['attentionLabel'] }}</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm leading-6 text-gray-700 dark:text-gray-300">{{ $operation['outcomeSentence'] }}</p>
|
||||
|
||||
@if (filled($operation['timingLabel'] ?? null))
|
||||
<div class="mt-2 text-xs font-medium text-gray-500 dark:text-gray-400">{{ $operation['timingLabel'] }}</div>
|
||||
@endif
|
||||
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $operation['type'] }}</div>
|
||||
<x-filament::badge :color="$operation['statusTone']">{{ $operation['statusLabel'] }}</x-filament::badge>
|
||||
<x-filament::badge :color="$operation['outcomeTone']">{{ $operation['outcomeLabel'] }}</x-filament::badge>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400"><span class="font-medium text-gray-700 dark:text-gray-300">{{ __('localization.dashboard.overview.label_reason') }}:</span> {{ $operation['reason'] }}</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"><span class="font-medium text-gray-700 dark:text-gray-300">{{ __('localization.dashboard.overview.label_impact') }}:</span> {{ $operation['impact'] }}</p>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $operation['summary'] }}
|
||||
|
||||
<div class="shrink-0 max-sm:ml-0 sm:ml-4">
|
||||
<x-filament::button
|
||||
data-testid="tenant-dashboard-operations-attention-item-action"
|
||||
tag="a"
|
||||
:href="$operation['primaryActionUrl']"
|
||||
size="sm"
|
||||
>
|
||||
{{ $operation['primaryActionLabel'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
@if ($operation['createdAt']) {{ $operation['createdAt'] }} @endif
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Right Column (Aside) -->
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
$operation = OperationRun::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
@ -73,30 +73,42 @@
|
||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity-icon\"]') !== null", true)
|
||||
->assertScript("(() => { const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); const firstKpi = document.querySelector('[data-testid=\"tenant-dashboard-kpi\"]'); if (! chips || ! firstKpi) return false; return chips.getBoundingClientRect().top < firstKpi.getBoundingClientRect().top; })()", true)
|
||||
->assertSee('Recommended next actions')
|
||||
->assertSee('Active operations')
|
||||
->assertSee('Operations needing attention')
|
||||
->assertSee('Operations requiring attention')
|
||||
->assertSee('Review operation')
|
||||
->assertSee('Open operations hub')
|
||||
->assertSee('Current review')
|
||||
->assertSee('Risk exceptions')
|
||||
->assertSee('Provider Health')
|
||||
->assertSee('Customer-safe output')
|
||||
->assertDontSee('Recent operations')
|
||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"]').length === 4", true)
|
||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-has-icon=\"true\"]').length === 4", true)
|
||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-has-chart=\"true\"]').length === 2", true)
|
||||
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status-icon\"]'); return rows.length > 0 && rows.length === icons.length; })()", true)
|
||||
->assertScript("(() => { const rows = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); return rows.length > 0 && rows.every((row) => { const interactive = row.getAttribute('data-governance-interactive') === 'true'; return interactive ? row.tagName === 'A' : row.tagName === 'DIV'; }); })()", true)
|
||||
->assertScript("(() => { const governance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); const operations = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]')); const rows = [...governance, ...operations]; return rows.length > 0 && rows.every((row) => row.getAttribute('data-overview-row-style') === 'secondary-list-row'); })()", true)
|
||||
->assertScript("(() => { const interactiveGovernance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"][data-governance-interactive=\"true\"]')); const operations = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]')); const rows = [...interactiveGovernance, ...operations]; return rows.length > 0 && rows.every((row) => row.className.includes('hover:shadow-md') && row.className.includes('hover:ring-1')); })()", true)
|
||||
->assertScript("(() => { const governance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); return governance.length > 0 && governance.every((row) => row.getAttribute('data-overview-row-style') === 'secondary-list-row'); })()", true)
|
||||
->assertScript("(() => { const interactiveGovernance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"][data-governance-interactive=\"true\"]')); return interactiveGovernance.length === 0 || interactiveGovernance.every((row) => row.className.includes('hover:shadow-md') && row.className.includes('hover:ring-1')); })()", true)
|
||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"high_severity_findings\"][data-kpi-has-chart=\"true\"]') !== null", true)
|
||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"active_operations\"][data-kpi-has-chart=\"true\"]') !== null", true)
|
||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"overdue_findings\"][data-kpi-has-chart=\"true\"]') === null", true)
|
||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"missing_permissions\"][data-kpi-has-chart=\"true\"]') === null", true)
|
||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action\"]').length <= 3", true)
|
||||
->assertScript("(() => { const actions = document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action-icon\"]'); return actions.length === 0 || icons.length === actions.length; })()", true)
|
||||
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation-icon\"]'); return rows.length === icons.length; })()", true)
|
||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-summary\"]') !== null", true)
|
||||
->assertScript("(() => { const card = document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-summary\"]'); if (! card) return false; return card.className.includes('border-gray-200') && card.className.includes('bg-white') && ! card.className.includes('border-warning-200') && ! card.className.includes('bg-warning-50'); })()", true)
|
||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-secondary-action\"]') !== null", true)
|
||||
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); return rows.length >= 1 && rows.length <= 3; })()", true)
|
||||
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item-icon\"]'); return rows.length === icons.length; })()", true)
|
||||
->assertScript("(() => { const item = document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); if (! item) return false; return item.className.includes('border-gray-200') && item.className.includes('border-l-4') && item.className.includes('border-l-warning-400') && ! item.className.includes('border-warning-200') && ! item.className.includes('bg-warning-50'); })()", true)
|
||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-readiness-card\"]').length === 4", true)
|
||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-readiness-card\"][data-readiness-key=\"provider_health\"]') !== null", true)
|
||||
->assertScript("! document.body.innerHTML.includes('fixed bottom-4 right-4 z-[999999] w-96 space-y-2')", true)
|
||||
->assertScript("(() => { const overview = document.querySelector('[data-testid=\"tenant-dashboard-overview\"]'); const main = document.querySelector('[data-testid=\"tenant-dashboard-overview-main\"]'); if (! overview || ! main) return false; const overviewWidth = overview.getBoundingClientRect().width; const mainWidth = main.getBoundingClientRect().width; return overviewWidth >= 600 && mainWidth >= 400; })()", true)
|
||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-overview\"] table').length === 0", true)
|
||||
->click('Review operation')
|
||||
->waitForText('Show all operations')
|
||||
->assertScript("window.location.pathname.includes('/admin/operations/{$operation->getKey()}')", true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
@ -89,6 +91,82 @@ function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpr
|
||||
->toBe('/admin/tenants/'.urlencode((string) $tenant->external_id).'/required-permissions?source=tenant_dashboard');
|
||||
});
|
||||
|
||||
it('prioritizes operations requiring attention below permissions and high severity findings and keeps canonical hub links', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
mockTenantDashboardActionPermissions([
|
||||
'overall' => 'blocked',
|
||||
'counts' => [
|
||||
'missing_application' => 1,
|
||||
'missing_delegated' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'started_at' => now()->subMinutes(2),
|
||||
'completed_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$summary = app(TenantDashboardSummaryBuilder::class)
|
||||
->build($tenant, $user)
|
||||
->toArray();
|
||||
|
||||
$activeOperationSummary = $summary['activeOperationSummary'] ?? null;
|
||||
$recommendedActions = $summary['recommendedActions'] ?? [];
|
||||
|
||||
expect($activeOperationSummary)
|
||||
->not->toBeNull()
|
||||
->and($activeOperationSummary['items'][0]['primaryActionLabel'] ?? null)->toBe('Review operation')
|
||||
->and($activeOperationSummary['items'][0]['primaryActionUrl'] ?? null)->toBe(OperationRunLinks::view($run, $tenant))
|
||||
->and($activeOperationSummary['secondaryActionLabel'] ?? null)->toBe('Open operations hub')
|
||||
->and($activeOperationSummary['secondaryActionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP))
|
||||
->and(array_column($recommendedActions, 'key'))->toBe([
|
||||
'required_permissions',
|
||||
'high_severity_findings',
|
||||
'operations_requiring_attention',
|
||||
])
|
||||
->and($recommendedActions[2]['title'] ?? null)->toBe('Review operations requiring attention')
|
||||
->and($recommendedActions[2]['reason'] ?? null)->toBe('One or more operations finished with an outcome that needs follow-up.')
|
||||
->and($recommendedActions[2]['impact'] ?? null)->toBe('The tenant should not be treated as fully healthy until the operation outcome has been reviewed.')
|
||||
->and($recommendedActions[2]['actionLabel'] ?? null)->toBe('Review operations')
|
||||
->and($recommendedActions[2]['actionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP));
|
||||
});
|
||||
|
||||
it('uses review permissions as the top recommended-action CTA when permissions are the highest follow-up', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
mockTenantDashboardActionPermissions([
|
||||
'overall' => 'blocked',
|
||||
'counts' => [
|
||||
'missing_application' => 2,
|
||||
'missing_delegated' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
$recommendedActions = app(TenantDashboardSummaryBuilder::class)
|
||||
->build($tenant, $user)
|
||||
->toArray()['recommendedActions'];
|
||||
|
||||
expect($recommendedActions[0]['key'] ?? null)->toBe('required_permissions')
|
||||
->and($recommendedActions[0]['title'] ?? null)->toBe('Review permissions')
|
||||
->and($recommendedActions[0]['actionLabel'] ?? null)->toBe('Review permissions')
|
||||
->and($recommendedActions[0]['actionUrl'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant));
|
||||
});
|
||||
|
||||
it('orders productized recommended actions by priority and caps the visible list at three repo-real CTAs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -197,6 +198,37 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
|
||||
]));
|
||||
});
|
||||
|
||||
it('derives the primary header CTA from the top recommended action instead of hard-coding operations copy', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
mockTenantDashboardAuthorizationPermissions([
|
||||
'overall' => 'blocked',
|
||||
'counts' => [
|
||||
'missing_application' => 1,
|
||||
'missing_delegated' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$summary = app(TenantDashboardSummaryBuilder::class)
|
||||
->build($tenant, $user)
|
||||
->toArray();
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(TenantDashboard::class)
|
||||
->assertActionVisible('primaryFollowUp');
|
||||
|
||||
$primaryAction = collect(tenantDashboardProductizationHeaderActions($component))
|
||||
->first(static fn ($action): bool => $action instanceof Action && $action->getName() === 'primaryFollowUp');
|
||||
|
||||
expect($summary['recommendedActions'][0]['actionLabel'] ?? null)->toBe('Review permissions')
|
||||
->and($summary['recommendedActions'][0]['actionUrl'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||
->and($primaryAction)->toBeInstanceOf(Action::class)
|
||||
->and($primaryAction->getLabel())->toBe('Review permissions')
|
||||
->and($primaryAction->getUrl())->toBe(RequiredPermissionsLinks::requiredPermissions($tenant));
|
||||
});
|
||||
|
||||
it('renders governance status rows as interactive only when a repo-real follow-up url is available', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||
@ -78,17 +79,20 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
->assertSee($tenant->name)
|
||||
->assertSee('Recommended next actions')
|
||||
->assertSee('Governance status')
|
||||
->assertSee('Operations needing attention')
|
||||
->assertSee('Current review')
|
||||
->assertSee('Risk exceptions')
|
||||
->assertSee('Provider Health')
|
||||
->assertSee('Customer-safe output')
|
||||
->assertSee('Recent operations');
|
||||
->assertSee('Operations requiring attention')
|
||||
->assertSee('Review operation')
|
||||
->assertSee('Open operations hub')
|
||||
->assertDontSee('Recent operations');
|
||||
|
||||
$content = $response->getContent();
|
||||
$contextChipsPosition = strpos($content, 'data-testid="tenant-dashboard-context-chips"');
|
||||
$firstKpiPosition = strpos($content, 'data-testid="tenant-dashboard-kpi"');
|
||||
$governanceStatusCount = substr_count($content, 'data-testid="tenant-dashboard-governance-status"');
|
||||
$recentOperationCount = substr_count($content, 'data-testid="tenant-dashboard-recent-operation"');
|
||||
$secondaryListRowCount = substr_count($content, 'data-overview-row-style="secondary-list-row"');
|
||||
|
||||
expect(substr_count($content, 'data-testid="tenant-dashboard-kpi"'))->toBe(4)
|
||||
@ -108,7 +112,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
->and($contextChipsPosition)->not->toBeFalse()
|
||||
->and($firstKpiPosition)->not->toBeFalse()
|
||||
->and($contextChipsPosition)->toBeLessThan($firstKpiPosition)
|
||||
->and($secondaryListRowCount)->toBe($governanceStatusCount + $recentOperationCount)
|
||||
->and($secondaryListRowCount)->toBe($governanceStatusCount)
|
||||
->and($content)->toContain('hover:shadow-md')
|
||||
->and($content)->toContain('hover:ring-1')
|
||||
->and(substr_count($content, 'data-kpi-has-icon="true"'))->toBe(4)
|
||||
@ -116,12 +120,13 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
->and(substr_count($content, 'data-testid="tenant-dashboard-recommended-action"'))->toBeLessThanOrEqual(3)
|
||||
->and(substr_count($content, 'tenant-dashboard-recommended-actions'))->toBeGreaterThanOrEqual(1)
|
||||
->and(substr_count($content, 'data-testid="tenant-dashboard-governance-status-icon"'))->toBe(substr_count($content, 'data-testid="tenant-dashboard-governance-status"'))
|
||||
->and(substr_count($content, 'data-testid="tenant-dashboard-recent-operation-icon"'))->toBe(substr_count($content, 'data-testid="tenant-dashboard-recent-operation"'))
|
||||
->and(substr_count($content, 'data-testid="tenant-dashboard-operations-attention-item-icon"'))->toBeGreaterThanOrEqual(1)
|
||||
->and(substr_count($content, 'data-testid="tenant-dashboard-readiness-card"'))->toBe(4)
|
||||
->and($content)->toContain('data-readiness-key="provider_health"')
|
||||
->and($content)->not->toContain('Open customer workspace')
|
||||
->and($content)->not->toContain('fixed bottom-4 right-4 z-[999999] w-96 space-y-2')
|
||||
->and($content)->toContain('High severity findings');
|
||||
->and($content)->toContain('High severity findings')
|
||||
->and($content)->not->toContain('section_recent_operations');
|
||||
});
|
||||
|
||||
it('adds repo-real icon metadata and only supported sparkline series to tenant dashboard kpis', function (): void {
|
||||
@ -201,6 +206,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
'missing_permissions',
|
||||
'active_operations',
|
||||
])
|
||||
->and($kpis['active_operations']['label'])->toBe('Operations needing attention')
|
||||
->and($kpis->pluck('icon')->filter()->count())->toBe(4)
|
||||
->and($kpis['high_severity_findings']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
||||
->and($kpis['high_severity_findings']['description'])->toBe('4 active · 4 new in 7d')
|
||||
@ -210,7 +216,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
->and($kpis['missing_permissions']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
||||
->and($kpis['missing_permissions']['description'])->toBe('2 app · 1 delegated missing')
|
||||
->and($kpis['active_operations']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
||||
->and($kpis['active_operations']['description'])->toBe('3 need follow-up · 3 in 7d')
|
||||
->and($kpis['active_operations']['description'])->toBe('3 operations require attention')
|
||||
->and($kpis['active_operations']['chart'])->toBe([0, 1, 0, 0, 2, 0, 0])
|
||||
->and($kpis['overdue_findings']['chart'])->toBeNull()
|
||||
->and($kpis['missing_permissions']['chart'])->toBeNull();
|
||||
@ -219,7 +225,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
}
|
||||
});
|
||||
|
||||
it('adds semantic icon metadata to governance status rows and repo-real recent operation types', function (): void {
|
||||
it('adds semantic icon metadata to governance status rows and curated operations attention items', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
mockTenantDashboardSummaryPermissions();
|
||||
@ -229,7 +235,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'created_at' => now()->subMinutes(3),
|
||||
'completed_at' => now()->subMinutes(3),
|
||||
]);
|
||||
@ -239,7 +245,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'tenant.review_pack.generate',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'created_at' => now()->subMinutes(2),
|
||||
'completed_at' => now()->subMinutes(2),
|
||||
]);
|
||||
@ -249,7 +255,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'outcome' => OperationRunOutcome::Blocked->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'completed_at' => now()->subMinute(),
|
||||
]);
|
||||
@ -259,16 +265,16 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
->toArray();
|
||||
|
||||
$governanceStatus = collect($summary['governanceStatus'])->keyBy('key');
|
||||
$recentOperations = collect($summary['recentOperations'])->keyBy('type');
|
||||
$attentionOperations = collect($summary['activeOperationSummary']['items'] ?? [])->keyBy('type');
|
||||
|
||||
expect($governanceStatus['baseline_compare']['icon'] ?? null)->toBe('heroicon-m-arrows-right-left')
|
||||
->and($governanceStatus['evidence_coverage']['icon'] ?? null)->toBe('heroicon-m-document-check')
|
||||
->and($governanceStatus['review_freshness']['icon'] ?? null)->toBe('heroicon-m-clipboard-document-check')
|
||||
->and($governanceStatus['provider_permissions']['icon'] ?? null)->toBe('heroicon-m-key')
|
||||
->and($governanceStatus['backup_posture']['icon'] ?? null)->toBe('heroicon-m-archive-box')
|
||||
->and($recentOperations['Inventory sync']['icon'] ?? null)->toBe('heroicon-m-arrow-path')
|
||||
->and($recentOperations['Review pack generation']['icon'] ?? null)->toBe('heroicon-m-document-arrow-down')
|
||||
->and($recentOperations['Permission posture check']['icon'] ?? null)->toBe('heroicon-m-key');
|
||||
->and($attentionOperations['Inventory sync']['icon'] ?? null)->toBe('heroicon-m-arrow-path')
|
||||
->and($attentionOperations['Review pack generation']['icon'] ?? null)->toBe('heroicon-m-document-arrow-down')
|
||||
->and($attentionOperations['Permission posture check']['icon'] ?? null)->toBe('heroicon-m-key');
|
||||
});
|
||||
|
||||
it('shows calm honest fallbacks when no urgent tenant follow-up is visible', function (): void {
|
||||
@ -290,14 +296,130 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
||||
$response = $this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('No immediate action is waiting.')
|
||||
->assertSee('Recent operations');
|
||||
->assertDontSee('Recent operations')
|
||||
->assertDontSee('Operations requiring attention');
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
$recentOperationCount = substr_count($content, 'data-testid="tenant-dashboard-recent-operation"');
|
||||
|
||||
expect(substr_count($content, 'data-testid="tenant-dashboard-recommended-actions-empty"'))->toBe(1)
|
||||
->and($recentOperationCount)->toBeGreaterThan(0)
|
||||
->and($recentOperationCount)->toBeLessThanOrEqual(4)
|
||||
->and($content)->not->toContain('data-testid="tenant-dashboard-operations-attention-summary"')
|
||||
->and($content)->not->toContain('data-testid="tenant-dashboard-recent-operations-empty"');
|
||||
});
|
||||
|
||||
it('builds a curated operations requiring attention summary and excludes healthy active runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
mockTenantDashboardSummaryPermissions();
|
||||
|
||||
$healthyRunningRun = OperationRun::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$followUpRun = OperationRun::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'created_at' => now()->subMinutes(6),
|
||||
'started_at' => now()->subMinutes(5),
|
||||
'completed_at' => now()->subMinutes(4),
|
||||
]);
|
||||
|
||||
$blockedRun = OperationRun::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Blocked->value,
|
||||
'created_at' => now()->subMinutes(5),
|
||||
'started_at' => now()->subMinutes(4),
|
||||
'completed_at' => now()->subMinutes(3),
|
||||
]);
|
||||
|
||||
$summary = app(TenantDashboardSummaryBuilder::class)
|
||||
->build($tenant, $user)
|
||||
->toArray();
|
||||
|
||||
$activeOperationSummary = $summary['activeOperationSummary'] ?? null;
|
||||
$items = collect($activeOperationSummary['items'] ?? []);
|
||||
|
||||
expect($activeOperationSummary)
|
||||
->not->toBeNull()
|
||||
->and($activeOperationSummary['title'] ?? null)->toBe('Operations requiring attention')
|
||||
->and($activeOperationSummary['count'] ?? null)->toBe(2)
|
||||
->and($activeOperationSummary['secondaryActionLabel'] ?? null)->toBe('Open operations hub')
|
||||
->and($activeOperationSummary['secondaryActionUrl'] ?? null)->toBe(OperationRunLinks::index(
|
||||
$tenant,
|
||||
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
))
|
||||
->and($items)->toHaveCount(2)
|
||||
->and($items->pluck('id')->all())->toBe([
|
||||
(int) $blockedRun->getKey(),
|
||||
(int) $followUpRun->getKey(),
|
||||
])
|
||||
->and($items->pluck('primaryActionLabel')->unique()->all())->toBe(['Review operation'])
|
||||
->and($items->pluck('primaryActionUrl')->all())->toBe([
|
||||
OperationRunLinks::view($blockedRun, $tenant),
|
||||
OperationRunLinks::view($followUpRun, $tenant),
|
||||
])
|
||||
->and($items->pluck('attentionLabel')->unique()->all())->toBe(['Follow-up required'])
|
||||
->and($items->pluck('timingLabel')->filter()->isNotEmpty())->toBeTrue()
|
||||
->and($items->pluck('outcomeSentence')->filter()->isNotEmpty())->toBeTrue()
|
||||
->and($items->pluck('reason')->filter()->isNotEmpty())->toBeTrue()
|
||||
->and($items->pluck('impact')->filter()->isNotEmpty())->toBeTrue()
|
||||
->and($items->pluck('id')->contains((int) $healthyRunningRun->getKey()))->toBeFalse();
|
||||
|
||||
$this->actingAs($user);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('data-testid="tenant-dashboard-operations-attention-summary"', false)
|
||||
->assertSee('Review operation')
|
||||
->assertSee('Open operations hub')
|
||||
->assertSee('Completed '.$followUpRun->completed_at?->diffForHumans())
|
||||
->assertSee('Inventory sync')
|
||||
->assertDontSee('Operation #'.$followUpRun->getKey())
|
||||
->assertDontSee('Recent operations');
|
||||
});
|
||||
|
||||
it('omits the compact active operations summary when no qualifying visible run exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
mockTenantDashboardSummaryPermissions();
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'created_at' => now()->subMinutes(3),
|
||||
'started_at' => now()->subMinutes(2),
|
||||
'completed_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$summary = app(TenantDashboardSummaryBuilder::class)
|
||||
->build($tenant, $user)
|
||||
->toArray();
|
||||
|
||||
expect($summary['activeOperationSummary'] ?? null)->toBeNull();
|
||||
|
||||
$this->actingAs($user);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('data-testid="tenant-dashboard-operations-attention-summary"', false)
|
||||
->assertDontSee('Review operation')
|
||||
->assertDontSee('Open operations hub')
|
||||
->assertDontSee('Recent operations');
|
||||
});
|
||||
|
||||
@ -247,7 +247,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
||||
'High severity findings',
|
||||
'Overdue findings',
|
||||
'Missing permissions',
|
||||
'Active operations',
|
||||
'Operations needing attention',
|
||||
]);
|
||||
|
||||
expect($stats['High severity findings'])->toMatchArray([
|
||||
@ -263,7 +263,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->and((int) $stats['Missing permissions']['value'])->toBeGreaterThan(0)
|
||||
->and($stats['Missing permissions']['url'])->not->toBeNull()
|
||||
->and($stats['Active operations'])->toMatchArray([
|
||||
->and($stats['Operations needing attention'])->toMatchArray([
|
||||
'value' => '3',
|
||||
'url' => OperationRunLinks::index(
|
||||
$tenant,
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations as DashboardRecentOperations;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
@ -25,7 +24,7 @@
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$operation = OperationRun::factory()->create([
|
||||
OperationRun::factory()->create([
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
@ -53,26 +52,22 @@
|
||||
Bus::fake();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
assertNoOutboundHttp(function () use ($operation, $tenant): void {
|
||||
assertNoOutboundHttp(function () use ($tenant): void {
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('/admin/choose-workspace', false);
|
||||
// NeedsAttention, RecentOperations and RecentDriftFindings are
|
||||
// lazy-loaded widgets and will not appear in the initial
|
||||
// server-rendered HTML.
|
||||
->assertSee('/admin/choose-workspace', false)
|
||||
->assertDontSee('data-testid="tenant-dashboard-operations-attention-summary"', false)
|
||||
->assertDontSee('Review operation')
|
||||
->assertDontSee('Open operations hub')
|
||||
->assertDontSee('Recent operations');
|
||||
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Healthy');
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
->assertSee('Active operations')
|
||||
->assertSee('No follow-up queued');
|
||||
|
||||
Livewire::test(DashboardRecentOperations::class)
|
||||
->assertSee('Operation ID')
|
||||
->assertSee('Operation #'.$operation->getKey())
|
||||
->assertSee('Inventory sync');
|
||||
->assertSee('Operations needing attention')
|
||||
->assertSee('No operations need attention');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
# Specification Quality Checklist: Tenant Dashboard Active Operations Summary Card
|
||||
|
||||
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
|
||||
**Created**: 2026-05-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] The package stays on one compact Tenant Dashboard active-operations summary card over existing `OperationRun` truth instead of widening into a full shell banner, dashboard-native operations console, or new widget framework.
|
||||
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff.
|
||||
- [x] The package explicitly names the repo-real anchors it builds on: `TenantDashboardSummaryBuilder`, `TenantDashboardOverview`, `ActiveRuns`, `OperationRunLinks`, `OperationUxPresenter`, existing `OperationRun` dashboard scopes, and the current dashboard Feature and browser proof owners.
|
||||
- [x] Mandatory repo sections for scope, shared-pattern reuse, Ops-UX, testing, proportionality, and manual-promotion rationale are completed.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No unresolved clarification markers remain.
|
||||
- [x] Requirements stay testable and bounded to the no-active hidden state, queued or running visible state, stale or follow-up-needed priority, canonical links, visibility or capability gating, and tenant or workspace isolation.
|
||||
- [x] The package explicitly preserves one dominant `View operation` action plus the neutral canonical `Show all operations` action.
|
||||
- [x] The package explicitly forbids a full dashboard shell banner, new persistence, new lifecycle ownership, a new widget family, a route or panel family change, provider or asset changes, and raw diagnostics expansion.
|
||||
- [x] Planned validation commands now match across `spec.md`, `plan.md`, and `tasks.md`.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and is a deliberate manual promotion that stays directionally consistent with the roadmap's dashboard/core-surface guidance after Specs `268` through `272`, even though it is not called out as an explicitly ranked roadmap item.
|
||||
- [x] The automatic next-best-prep queue is intentionally empty, so this package records itself as a deliberate manual promotion rather than an automatic queue pick.
|
||||
- [x] Repo verification confirms the current Tenant Dashboard already has the `Active operations` KPI and `Recent operations` history surface but still lacks the dedicated compact active-operations summary card this package defines.
|
||||
- [x] The chosen slice is smaller and safer than deferred alternatives such as a dashboard shell-banner rollout, a new dashboard operations console, or broader progress redesign work.
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] The package reuses current `OperationRun` truth and current dashboard summary composition instead of introducing a second lifecycle, a persisted projection, or a dashboard-only query family.
|
||||
- [x] The package explicitly keeps tenant or workspace isolation and `404` or suppression behavior intact for non-members and actors without `OperationRun` visibility.
|
||||
- [x] The package forbids new panel, provider, global-search, asset-registration, queue-family, notification-policy, and raw-diagnostics changes.
|
||||
- [x] The tasks artifact names the likely implementation and proof files already identified in `plan.md`.
|
||||
- [x] The review artifact, workflow outcome, and test-governance outcome are carried into the active prep package.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned proof stays bounded to focused `Feature` coverage plus one named dashboard `Browser` smoke.
|
||||
- [x] No new heavy-governance family or broad browser family is introduced by default.
|
||||
- [x] Fixture growth remains bounded to current tenant dashboard helpers, `OperationRun` factories, tenant context setup, and the existing dashboard browser scaffolding.
|
||||
- [x] The proving commands stay file-scoped, run through Sail, and keep DB-only visibility or isolation proof explicit instead of widening into unrelated lanes.
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against `specs/273-tenant-dashboard-active-operations-summary-card/spec.md`, `specs/273-tenant-dashboard-active-operations-summary-card/plan.md`, `specs/273-tenant-dashboard-active-operations-summary-card/tasks.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `.specify/memory/constitution.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php`, `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php`, `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`, `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, and `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` on 2026-05-07.
|
||||
- This checklist is the prep-time outcome record. If implementation widens into a full shell banner, a new widget family, new progress semantics, new persistence, route or panel changes, or raw-diagnostics expansion, the workflow outcome must change before merge.
|
||||
- No application implementation was performed while preparing this package.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: `acceptable-special-case`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Reason**: the automatic queue is intentionally empty, but repo truth still shows one bounded unspecced dashboard seam after Specs `268` through `272`: the Tenant Dashboard already has aggregate and recent-history signals, yet still lacks the compact active-operations summary card this package defines.
|
||||
- **Workflow result**: Ready for implementation.
|
||||
@ -0,0 +1,100 @@
|
||||
# Implementation Plan: Tenant Dashboard Operations Curation & Decision-First UX
|
||||
|
||||
**Branch**: `273-tenant-dashboard-active-operations-summary-card` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This scope refresh narrows the Tenant Dashboard back to decision-first operations UX. The dashboard keeps one attention KPI, one attention-only recommended action, and one compact `Operations requiring attention` card. It stops rendering recent operations history on the dashboard and pushes detail/history back to the canonical Operations Hub.
|
||||
|
||||
## Implementation Shape
|
||||
|
||||
- Keep all logic inside the existing `TenantDashboardSummaryBuilder` + `TenantDashboardOverview` path.
|
||||
- Reuse `OperationRun::dashboardNeedsFollowUp()`, `OperationRunLinks`, and `OperationUxPresenter`.
|
||||
- Reuse canonical Operations Hub filters by dominant real problem class.
|
||||
- Remove the recent-operations overview section from the tenant dashboard Blade.
|
||||
- Keep Filament v5 + Livewire v4, no provider-registration changes, no new panels/resources/assets.
|
||||
|
||||
## Affected Surfaces
|
||||
|
||||
- `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php`
|
||||
- `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php`
|
||||
- `apps/platform/lang/en/localization.php`
|
||||
- `apps/platform/lang/de/localization.php`
|
||||
- `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`
|
||||
- `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||
- `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Query contract
|
||||
|
||||
- Centralize dashboard attention logic in one tenant/workspace-scoped attention query.
|
||||
- Attention means only `dashboardNeedsFollowUp()` runs.
|
||||
- Healthy active runs are excluded from the dashboard decision card.
|
||||
|
||||
### KPI contract
|
||||
|
||||
- Keep the KPI slot and chart.
|
||||
- Replace history-window wording with attention-only decision copy.
|
||||
- KPI click remains a canonical Operations Hub drill-through.
|
||||
|
||||
### Recommended action contract
|
||||
|
||||
- Promote operations attention above informative readiness states.
|
||||
- Keep it below missing permissions and high severity findings.
|
||||
- Use exact review-oriented copy from the scope refresh.
|
||||
|
||||
### Card contract
|
||||
|
||||
- Show at most three attention items.
|
||||
- Per item: title, outcome sentence, reason, impact, time, `Review operation`.
|
||||
- Card-level secondary CTA: `Open operations hub`.
|
||||
- No recent-history list remains on the dashboard.
|
||||
|
||||
## RBAC / Isolation
|
||||
|
||||
- Tenant membership stays the first gate.
|
||||
- All counts and items remain scoped by `managed_environment_id` and `workspace_id`.
|
||||
- If the actor cannot access tenant operations, the dashboard card and links stay hidden.
|
||||
|
||||
## Validation
|
||||
|
||||
- Focused tests:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
|
||||
- Formatting:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not add a second operations inbox or history block to the dashboard.
|
||||
- Do not invent dashboard-only filters or route strings.
|
||||
- Do not expose raw diagnostics or payload details on the dashboard.
|
||||
- Do not change provider registration, global search, or destructive-action behavior.
|
||||
|
||||
### Phase 1 - Add one derived active-operations summary payload
|
||||
|
||||
- Extend `TenantDashboardSummaryBuilder` with one compact summary payload that returns count, highlighted run, status/guidance, and canonical navigation actions.
|
||||
- Reuse `ActiveRuns`, `OperationRun::dashboardNeedsFollowUp()`, `OperationUxPresenter`, and `OperationRunLinks` instead of inventing a new query or presenter layer.
|
||||
- Keep ranking deterministic: follow-up-needed/stale first, healthy queued/running second, then recency.
|
||||
|
||||
### Phase 2 - Render the card inside the existing overview composition
|
||||
|
||||
- Add the compact card to `tenant-dashboard-overview.blade.php` inside the current main-column composition.
|
||||
- Use current dashboard card language and Filament primitives, keep one dominant `View operation` action, and keep `Show all operations` neutral.
|
||||
- Hide the card entirely when no qualifying visible signal exists.
|
||||
|
||||
### Phase 3 - Prove calmness, truth, and tenant-safe visibility
|
||||
|
||||
- Extend focused Feature coverage for visible, hidden, priority, and shared-link behavior.
|
||||
- Extend or reuse the existing negative tenant dashboard feature proof for non-member or no-OperationRun-visibility suppression.
|
||||
- Update the existing browser smoke so the real dashboard first screen proves the card appears only when warranted and does not regress the calm layout.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: the current Tenant Dashboard can show operations only as aggregate posture or recent history, which forces the operator to infer whether current tenant work needs attention right now.
|
||||
- **Existing structure is insufficient because**: the `Active operations` KPI is aggregate-only, and `Recent operations` is history-oriented. Neither surface provides one truthful highlighted run plus a dominant next action.
|
||||
- **Narrowest correct implementation**: one derived summary payload inside the current dashboard summary builder plus one compact card in the existing overview view.
|
||||
- **Ownership cost created**: a small summary-builder extension, one dashboard render slice, and focused feature/browser proof that must stay aligned with shared OperationRun helpers.
|
||||
- **Alternative intentionally rejected**: reusing the full shell banner or building a new Operations widget family was rejected because both would make the dashboard louder and broader than the governance-first contract requires.
|
||||
- **Release truth**: current-release truth. The repo already ships the dashboard, KPI, recent-operation cards, and canonical Operations surfaces; this plan only fills the bounded missing summary seam.
|
||||
@ -0,0 +1,305 @@
|
||||
# Feature Specification: Tenant Dashboard Operations Curation & Decision-First UX
|
||||
|
||||
**Feature Branch**: `273-tenant-dashboard-active-operations-summary-card`
|
||||
**Created**: 2026-05-07
|
||||
**Status**: Ready for implementation
|
||||
**Input**: repo-based scope refresh from the original active-operations-summary slice after product review identified dashboard drift: operations had become visible in too many places on the Tenant Dashboard and needed to be reduced to decision-only attention surfaces.
|
||||
|
||||
## Spec Candidate Check
|
||||
|
||||
- **Problem**: the Tenant Dashboard currently exposes operation truth through too many surfaces at once: KPI, recommended action, attention card, and recent-history rendering. That pushes the dashboard toward a second Operations hub instead of a governance-first decision surface.
|
||||
- **User-visible improvement**: the dashboard keeps only attention-relevant operations. Healthy active or recent historical runs stop taking space on the first screen, while stale or follow-up-needed runs stay visible with one clear next action.
|
||||
- **Smallest enterprise-capable version**: keep the `Active operations` KPI as an attention KPI, add one review-oriented recommended action when operations need follow-up, render one compact `Operations requiring attention` card with at most 1-3 runs, and remove the recent-operations section from the dashboard overview.
|
||||
- **Explicit non-goals**: no second operations console, no raw diagnostics on the dashboard, no new persistence, no new OperationRun lifecycle, no new panel/resource/search surface, no route-family changes, and no compatibility shims.
|
||||
- **Why now**: repo truth already ships canonical Operations list/detail surfaces. The remaining gap is not more operations visibility, but curation: the dashboard must stop duplicating history/detail surfaces and only show decision-relevant execution truth.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Primary route**: `/admin/t/{tenant}` Tenant Dashboard
|
||||
- **Canonical drill-through routes**:
|
||||
- `/admin/operations`
|
||||
- `/admin/operations/{run}`
|
||||
- **Source of truth**: existing tenant-scoped `operation_runs` only
|
||||
- **RBAC**: existing tenant membership and operation visibility remain authoritative. Non-members remain `404`, and no card, count, or CTA may leak hidden runs.
|
||||
|
||||
## Shared Pattern Reuse
|
||||
|
||||
- **Cross-cutting feature**: yes
|
||||
- **Interaction classes**: dashboard KPI, recommended actions, decision card, canonical run navigation
|
||||
- **Shared contracts reused**: `OperationRunLinks`, `OperationUxPresenter`, `ActiveRuns`, `TenantDashboardSummaryBuilder`
|
||||
- **Required consistency**:
|
||||
- Dashboard remains decision-first
|
||||
- Operations Hub remains diagnostics/history-first
|
||||
- `OperationRun` remains execution truth
|
||||
- No local dashboard-only run lifecycle or fake filters
|
||||
|
||||
## UX Contract
|
||||
|
||||
### A. KPI Card
|
||||
|
||||
- Keep the existing `Active operations` KPI slot.
|
||||
- Count only attention-relevant operations.
|
||||
- Show one short decision sentence:
|
||||
- `No operations need attention`
|
||||
- `1 operation needs follow-up`
|
||||
- `:count operations require attention`
|
||||
- KPI click opens the canonical Operations Hub in current tenant context with the dominant real attention filter.
|
||||
|
||||
### B. Recommended Next Actions
|
||||
|
||||
- Show a recommended action only when attention-relevant operations exist.
|
||||
- Required copy:
|
||||
- **Title**: `Review operations requiring attention`
|
||||
- **Reason**: `One or more operations finished with an outcome that needs follow-up.`
|
||||
- **Impact**: `The tenant should not be treated as fully healthy until the operation outcome has been reviewed.`
|
||||
- **Primary CTA**: `Review operations`
|
||||
- Priority rule:
|
||||
- lower than missing provider permissions
|
||||
- lower than high severity findings
|
||||
- higher than purely informative readiness/status items
|
||||
|
||||
### C. Operations Requiring Attention Card
|
||||
|
||||
- Render only when at least one attention-relevant run exists.
|
||||
- Card title: `Operations requiring attention`
|
||||
- Show at most 1-3 operations.
|
||||
- Per operation show:
|
||||
- clear title
|
||||
- human-readable outcome sentence
|
||||
- reason
|
||||
- impact
|
||||
- relative time
|
||||
- primary CTA: `Review operation`
|
||||
- Card-level secondary CTA: `Open operations hub`
|
||||
- Healthy queued/running work is excluded from this card.
|
||||
- Recent-history rendering is not shown on the Tenant Dashboard.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. The Tenant Dashboard does not render a Recent Operations section.
|
||||
2. Healthy queued/running operations do not create a dashboard attention card.
|
||||
3. Terminal follow-up and stale-active runs do create a dashboard attention signal.
|
||||
4. The KPI uses attention-only decision copy, not history-window copy.
|
||||
5. The recommended action appears only when attention-relevant operations exist and uses the required copy.
|
||||
6. The attention card shows at most 1-3 operations and each operation has `Review operation`.
|
||||
7. `Open operations hub` and KPI drill-through use canonical `OperationRunLinks` tenant-scoped URLs.
|
||||
8. The dashboard exposes no raw diagnostics, no execution-history table, and no additional operations overview block.
|
||||
9. Cross-tenant or hidden runs never affect dashboard counts, items, or links.
|
||||
|
||||
## Test & Validation
|
||||
|
||||
- **Test classification**: Feature + Browser
|
||||
- **Validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No Livewire v3 / Filament v3-v4 patterns. This remains Filament v5 + Livewire v4.
|
||||
- No provider-registration changes; `apps/platform/bootstrap/providers.php` remains authoritative.
|
||||
- No global-search impact.
|
||||
- No destructive actions are introduced.
|
||||
- non-member / not entitled to workspace scope OR tenant scope -> 404 (deny-as-not-found)
|
||||
- member but missing capability -> 403
|
||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||
- how the affected surface follows `docs/ui/tenantpilot-enterprise-ui-standards.md`,
|
||||
- which native Filament components or shared UI primitives are used,
|
||||
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||
- how the feature avoids ad-hoc custom styling for cards, buttons, hovers, badges, icons, progress bars, empty states, and interactive rows,
|
||||
- how any custom Blade, Livewire widget, page, or dashboard surface preserves Filament-native interaction semantics and avoids introducing an independent button, status-color, spacing, or card system,
|
||||
- how each affected page or focused action area keeps at most one dominant primary action and keeps secondary actions neutral unless they are destructive or the semantic state change is the point of the action,
|
||||
- how status is conveyed through BADGE-001 badges, labels, chips, or supporting text rather than arbitrary button colors or per-card custom action styling,
|
||||
- how hover, pointer, focus, shadow, or similar interactive affordance is used only when a repo-real route/action and permitted capability exist, and how non-interactive rows remain visibly static,
|
||||
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language, and are used to compose product-specific layout rather than a parallel local design system,
|
||||
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||
- the target object,
|
||||
- the operator verb,
|
||||
- whether source/domain disambiguation is actually needed,
|
||||
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** If this feature adds or changes operator-facing surfaces, the spec MUST describe:
|
||||
- whether each affected surface is a Primary Decision Surface,
|
||||
Secondary Context Surface, or Tertiary Evidence / Diagnostics
|
||||
Surface, and why,
|
||||
- which human-in-the-loop moment each primary surface supports,
|
||||
- what MUST be visible immediately for the first decision,
|
||||
- what is preserved but only revealed on demand,
|
||||
- why any new primary surface cannot live inside an existing decision
|
||||
context,
|
||||
- how navigation follows operator workflows rather than storage
|
||||
structures,
|
||||
- how one governance case remains decidable in one focused context,
|
||||
- how any new automation, notifications, or autonomous governance logic
|
||||
reduce search/review/click load,
|
||||
- and how the resulting default experience is calmer and clearer rather
|
||||
than merely larger.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||
- the chosen broad action-surface class and why it is the correct classification,
|
||||
- the chosen detailed surface type and why it is the correct refinement,
|
||||
- the one most likely next operator action,
|
||||
- the one and only primary inspect/open model,
|
||||
- whether row click is required, allowed, or forbidden,
|
||||
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||
- where pure navigation lives and why it is not competing with mutation,
|
||||
- where secondary actions live,
|
||||
- where destructive actions live,
|
||||
- how grouped actions are ordered by meaning, frequency, and risk,
|
||||
- the canonical collection route and canonical detail route,
|
||||
- the scope signals shown to the operator and what real effect each one has,
|
||||
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
||||
- which critical operational truth is visible by default,
|
||||
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** If this
|
||||
feature adds or materially changes header actions, row actions, bulk
|
||||
actions, or workbench controls, the spec MUST describe:
|
||||
- how navigation, mutation, context signals, selection actions, and
|
||||
dangerous actions are separated,
|
||||
- why any visible secondary action deserves primary-plane placement,
|
||||
- why any ActionGroup is structured rather than a mixed catch-all,
|
||||
- and why any workflow-hub, wizard, system, or other special-type
|
||||
exception is genuine rather than a convenience shortcut.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||
- which diagnostics are secondary and how they are explicitly revealed,
|
||||
- how the dominant next action stays primary and how duplicate visible truth is avoided,
|
||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||
- and the page contract for each new or materially refactored operator-facing page.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||
- which existing layer is replaced or why no existing layer can serve,
|
||||
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||
- and how tests focus on business consequences rather than thin indirection alone.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||
the spec MUST include a `UI Action Matrix` and explicitly state whether the Action Surface Contract is satisfied.
|
||||
The same section MUST state that each affected surface has exactly one primary inspect/open model, that redundant View actions are absent,
|
||||
that empty `ActionGroup` / `BulkActionGroup` placeholders are absent, and that destructive actions follow the required placement rules for the chosen surface type.
|
||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||
|
||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
|
||||
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The Tenant Dashboard MUST add only one compact active-operations summary surface inside the existing dashboard overview composition. It MUST NOT render the full Spec `268` shell activity banner on the dashboard by default.
|
||||
- **FR-002**: Card eligibility, count, and highlighted-run truth MUST be derived from existing tenant-scoped OperationRun truth and shared helpers already used for activity or follow-up visibility. The dashboard MUST NOT invent a second lifecycle query model or dashboard-only status taxonomy.
|
||||
- **FR-003**: When one or more qualifying visible runs are queued, running, stale, or otherwise still need follow-up in the current tenant, the dashboard MUST show one compact summary card that includes a count and one highlighted run.
|
||||
- **FR-004**: Highlighted-run selection MUST prioritize follow-up-needed or stale attention over healthy queued or running work. When multiple runs share the same attention class, the highlighted run MUST fall back to a deterministic recency rule.
|
||||
- **FR-005**: Default-visible card content MUST stay limited to concise count text, highlighted run label, one centralized status treatment, one short guidance line, and the canonical `View operation` and `Show all operations` navigation actions.
|
||||
- **FR-006**: `View operation` MUST open the canonical detail route for the highlighted run through shared link helpers. `Show all operations` MUST open the canonical Operations collection in current tenant context through shared link helpers. Dashboard code MUST NOT hardcode raw route strings.
|
||||
- **FR-007**: Failed, blocked, stale, or follow-up-needed work MUST receive stronger visual priority than healthy queued or running work. Healthy active work MUST remain a calm secondary signal rather than an alert banner.
|
||||
- **FR-008**: When more than one qualifying run exists, the card MUST stay compact by showing one highlighted run plus the aggregate count and collection drill-through. It MUST NOT expand into a multi-row operations list, table, or dashboard-native inbox.
|
||||
- **FR-009**: When no qualifying active or follow-up-needed runs are visible to the current actor, the active-operations summary card MUST remain hidden by default. Recently completed successful runs stay represented through `Recent operations` rather than keeping this card open.
|
||||
- **FR-010**: The card MUST reuse the dashboard's existing polling cadence and current first-screen density constraints. It MUST NOT add a second poller, floating overlay, or full-width panel that competes with recommended actions, governance status, or readiness cards.
|
||||
- **FR-011**: The card MUST complement rather than duplicate the existing `Active operations` KPI and `Recent operations` section. The KPI remains aggregate posture, `Recent operations` remains recent history, and the new card remains the current active-or-follow-up summary.
|
||||
- **FR-012**: The surface MUST stay governance-first and decision-first: one dominant next action, diagnostics-secondary disclosure, no raw implementation detail, and no dashboard-local operations-console affordances.
|
||||
|
||||
### Authorization and Safety Requirements
|
||||
|
||||
- **AR-001**: Existing tenant/admin-plane authorization semantics remain unchanged: non-members or out-of-scope tenant actors remain `404`, while canonical Operations routes continue using current OperationRun authorization for in-scope actors.
|
||||
- **AR-002**: The card's count, highlighted run, and both navigation actions MUST be limited to runs the current actor can already view through canonical Operations routes. If the actor cannot view OperationRuns for the current tenant, the card MUST stay hidden.
|
||||
- **AR-003**: No mutating or destructive action is introduced. Both card actions are navigation-only and must not imply acknowledgement, retry, dismissal, or lifecycle mutation.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-001**: Filament remains v5 on Livewire v4. No panel-provider registration change is allowed, and `bootstrap/providers.php` remains authoritative.
|
||||
- **NFR-002**: No new panel, globally searchable resource, or asset-registration strategy is introduced. `filament:assets` deployment behavior is unchanged.
|
||||
- **NFR-003**: The card must use existing dashboard composition patterns and Filament-native primitives or current shared dashboard styles. It must not introduce a new local card, badge, button, or hover language outside the current dashboard family.
|
||||
- **NFR-004**: Counted, phased, or composite progress semantics remain owned by Specs `270`, `271`, and `272`. This feature may consume current shared status and guidance truth, but it must not invent progress meters, fake percentages, or dashboard-only phase language.
|
||||
- **NFR-005**: The implementation must preserve current dark-mode correctness, responsive first-screen stability, and current dashboard interaction honesty. Non-interactive areas remain static, and navigation affordances appear only where real routes and permissions exist.
|
||||
|
||||
## Deferred Follow-Ups / Explicit Non-Goals
|
||||
|
||||
- dashboard-native shell-banner redesign or any full-width activity banner on the Tenant Dashboard
|
||||
- counted-progress dashboard treatment already owned by `270` and `271`
|
||||
- phase or composite progress treatment on the dashboard already owned by `272`
|
||||
- a dashboard-native operations inbox, table, or console
|
||||
- workspace-level or cross-tenant active-operations summary surfaces
|
||||
- raw diagnostics, logs, payloads, or support-only execution evidence on the dashboard
|
||||
- broader manual-promotion candidates such as `Governance Artifact Lifecycle & Retention v1` and `Enterprise Access Boundary & Support Access Governance v1`
|
||||
|
||||
## Key Entities
|
||||
|
||||
- **Active Operations Summary Card**: a derived, non-persisted Tenant Dashboard card that summarizes current active or follow-up-needed OperationRun truth for the current tenant without becoming a second operations console.
|
||||
- **Highlighted Operation Summary**: the single derived run preview inside the card that determines the dominant next action and keeps `View operation` truthful when multiple qualifying runs exist.
|
||||
- **Qualifying Dashboard Operation Signal**: an existing tenant-scoped OperationRun that is still active, stale, or otherwise needs follow-up and therefore deserves compact dashboard visibility.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Focused Feature proof shows the Tenant Dashboard rendering one compact active-operations summary card when at least one qualifying visible run exists, with one highlighted run plus canonical `View operation` and `Show all operations` actions.
|
||||
- **SC-002**: Focused Feature proof shows stale or follow-up-needed work outranking healthy queued or running work whenever both are visible for the current tenant.
|
||||
- **SC-003**: Focused Feature plus browser proof shows the dashboard remaining calm when no qualifying visible run exists: no summary card, no full shell banner, and no first-screen layout regression.
|
||||
- **SC-004**: Focused negative visibility proof shows actors without OperationRun visibility receiving no card content, no count leak, and no operation drill-through hints on the dashboard.
|
||||
- **SC-005**: Covered dashboard scenarios keep the existing `Active operations` KPI and `Recent operations` section present and non-contradictory, with no second OperationRun truth source introduced.
|
||||
|
||||
## Candidate Selection Rationale
|
||||
|
||||
- **Selected candidate**: Tenant Dashboard Active Operations Summary Card
|
||||
- **Source locations**:
|
||||
- `docs/product/spec-candidates.md`
|
||||
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
||||
- `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php`
|
||||
- `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php`
|
||||
- `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php`
|
||||
- `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`
|
||||
- `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
|
||||
- **Why selected**: the automatic next-best-prep queue is intentionally empty, Specs `268` through `272` already exist around OperationRun maturity, and repo exploration confirms the exact gap named by candidate `273`: the Tenant Dashboard currently has recent-operation cards and an `Active operations` KPI but no dedicated active-only summary card.
|
||||
- **Why this is the smallest viable implementation slice**: it adds one compact dashboard-native summary over existing shared OperationRun truth and canonical links, without reopening shell-banner scope, progress semantics, new persistence, or new widget infrastructure.
|
||||
- **Why close alternatives were deferred**:
|
||||
- Specs `269` through `272` are already specced and therefore are not the next unspecced repo-ready prep target
|
||||
- `Governance Artifact Lifecycle & Retention v1` and `Enterprise Access Boundary & Support Access Governance v1` remain broader, less bounded manual-promotion items that require larger product decisions than this dashboard follow-up
|
||||
|
||||
## Related-Spec Guardrail Check
|
||||
|
||||
- `specs/268-operationrun-activity-feedback/`: this feature reuses the same run truth family but must not transplant the full shell banner onto the Tenant Dashboard.
|
||||
- `specs/269-operationrun-terminal-outcome-feedback/`: any follow-up-needed or stale prioritization on the dashboard must stay aligned with the shell's terminal-outcome honesty rather than inventing a dashboard-only urgency model.
|
||||
- `specs/270-operationrun-progress-contract/`: the dashboard card must consume shared OperationRun truth and must not invent local progress heuristics or fake percentages.
|
||||
- `specs/271-counted-progress-rollout/`: any richer progress meter remains out of scope here and must flow through the counted-progress contract if it is ever added later.
|
||||
- `specs/272-operationrun-phase-composite-progress/`: phase/composite progress language remains deferred and must not be smuggled into the dashboard card before the shared progress contract owns it.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The canonical Operations collection already supports current-tenant drill-through behavior without requiring a new route family for this feature.
|
||||
- Existing OperationRun helpers are sufficient to support highlighted-run selection and canonical link generation without introducing a new shared presenter layer.
|
||||
- For v1, the calmest interpretation of the candidate's `hidden or calm empty state` rule is to keep the card hidden when there is no qualifying visible signal.
|
||||
|
||||
## Risks
|
||||
|
||||
- The card can become noisy or redundant if it duplicates the `Active operations` KPI or `Recent operations` instead of keeping one distinct current-summary role.
|
||||
- A dashboard-local prioritization rule can drift from shared shell or Operations semantics if the implementation stops reusing current shared helpers.
|
||||
- Permission or tenant-filter mistakes could leak run counts or run existence through the summary card even if the canonical Operations route remains protected.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking safe implementation. If layout pressure later proves that a persistent empty-state slot is necessary, that should remain a narrow presentation decision and must not widen this slice into a second dashboard operations surface.
|
||||
@ -0,0 +1,75 @@
|
||||
---
|
||||
description: "Task list for Tenant Dashboard Operations Curation & Decision-First UX"
|
||||
---
|
||||
|
||||
# Tasks: Tenant Dashboard Operations Curation & Decision-First UX
|
||||
|
||||
- [x] T001 Refresh the active local spec scope so the Tenant Dashboard slice is explicitly attention-only and no longer describes a recent-operations surface on the dashboard.
|
||||
- [x] T002 Update the focused dashboard tests to the new contract: no recent-operations section, attention-only KPI copy, operations attention recommended action, and `Operations requiring attention` card with `Review operation` / `Open operations hub`.
|
||||
- [x] T003 Re-run the focused dashboard test slice to capture the exact implementation failures against the new contract.
|
||||
- [x] T004 Refactor `TenantDashboardSummaryBuilder` to centralize a tenant/workspace-scoped attention query and reuse it for KPI counts, recommended action visibility, and the operations attention card.
|
||||
- [x] T005 Replace the old single highlighted-run payload with a curated 1-3 item attention-card payload that exposes title, outcome sentence, reason, impact, relative time, and canonical review links.
|
||||
- [x] T006 Update dashboard localization so KPI and recommended-action copy match the new decision-first operations wording in EN and DE.
|
||||
- [x] T007 Remove the recent-operations section from the tenant dashboard overview Blade and render the new attention-card layout with one card-level hub CTA and per-item review CTAs.
|
||||
- [x] T008 Keep the dashboard navigation and RBAC contract canonical: no new route strings, no cross-tenant leakage, no destructive actions, and no dashboard-owned operations history surface.
|
||||
- [x] T009 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`.
|
||||
- [x] T010 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- [x] T011 Review the changed slice against Filament v5 / Livewire v4 guardrails, canonical Operations Hub ownership, and dashboard decision-first UX boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks user-story work.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes the compact summary payload plus render slice.
|
||||
- **Phase 4 (US2)**: depends on US1 because the attention-first priority rule refines the highlighted summary already introduced there.
|
||||
- **Phase 5 (US3)**: depends on US1 and should land with US2 so the summary stays both truthful and quiet.
|
||||
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independently testable after Phase 2 and delivers the core compact-summary contract.
|
||||
- **US2 (P1)**: independently testable after US1 and delivers the follow-up-first attention ordering that makes the summary trustworthy.
|
||||
- **US3 (P1)**: independently testable after US1 and closes the no-signal or no-visibility calmness contract.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Extend the listed Pest coverage first and make it fail for the intended gap.
|
||||
- Keep runtime edits inside the current summary builder, current overview view, and current shared OperationRun helpers rather than introducing new dashboard infrastructure.
|
||||
- Re-run the narrowest affected validation command after each story checkpoint before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **US1 + US2 + US3 together**. The dashboard slice is only truthful once it appears when warranted, prioritizes stale or follow-up-needed work, and disappears when no qualifying or visible signal exists.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1 so the compact summary payload and card exist.
|
||||
3. Deliver US2 so the highlighted run stays attention-first.
|
||||
4. Deliver US3 so the no-signal and no-visibility behavior stays calm and leak-free.
|
||||
5. Finish with focused validation and the review-artifact close-out checks.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the proof owner first.
|
||||
2. Parallelize Feature and browser proof updates while keeping runtime changes local to the current summary builder and overview view.
|
||||
3. Serialize merges around `TenantDashboardSummaryBuilder.php` and `tenant-dashboard-overview.blade.php` so the compact card contract stays coherent.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Follow-Ups / Non-Goals
|
||||
|
||||
- full shell activity banner rollout on the Tenant Dashboard
|
||||
- dashboard-native operations console or a new dashboard widget framework
|
||||
- counted, phased, or composite progress rollout work already owned by Specs `270` through `272`
|
||||
- new `OperationRun` lifecycle, queue, or notification-policy changes
|
||||
- new persistence, cached summary projection, or raw-diagnostics expansion on the dashboard
|
||||
- route-family, panel, provider, asset, or global-search changes
|
||||
Loading…
Reference in New Issue
Block a user