Compare commits
21 Commits
bd0b733f48
...
c709b366f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c709b366f6 | ||
|
|
028fa817d1 | ||
|
|
b08ee2096f | ||
|
|
db80fc9492 | ||
|
|
15b798dac6 | ||
|
|
b2608a3470 | ||
|
|
a10c4914c4 | ||
|
|
2b10e086ea | ||
|
|
21b971008a | ||
|
|
7af716747e | ||
|
|
e74f32fe49 | ||
| 7148aa7f9d | |||
| 9848d29478 | |||
|
|
d505f3c65c | ||
|
|
05be853d93 | ||
|
|
a01888f629 | ||
|
|
cbca4b591e | ||
|
|
18316146a5 | ||
|
|
9752e5e90e | ||
|
|
469f0fac8c | ||
|
|
2ddb3dd20a |
36
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
36
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug
|
||||
about: Fehlerbericht / Regression
|
||||
title: "bug: <kurzer titel>"
|
||||
labels: ["bug"]
|
||||
---
|
||||
|
||||
## What happened?
|
||||
<!-- Beschreibe den Bug -->
|
||||
|
||||
## Expected behavior
|
||||
<!-- Was sollte passieren? -->
|
||||
|
||||
## Steps to reproduce
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
## Impact / Severity
|
||||
- [ ] blocker
|
||||
- [ ] high
|
||||
- [ ] medium
|
||||
- [ ] low
|
||||
|
||||
## Logs / Screenshots
|
||||
<!-- Relevante Logs, Stacktraces, Screenshots -->
|
||||
|
||||
## Environment
|
||||
- Branch/Commit:
|
||||
- Staging/Prod:
|
||||
- Browser (falls UI):
|
||||
- Relevant config/env:
|
||||
|
||||
## Fix criteria
|
||||
- [ ] Repro-Test vorhanden oder neuer Test hinzugefügt
|
||||
- [ ] Fix verifiziert (lokal + staging)
|
||||
41
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
41
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Feature
|
||||
about: Neues Feature / Erweiterung
|
||||
title: "feat(<NNN>): <kurzer titel>"
|
||||
labels: ["feature"]
|
||||
---
|
||||
|
||||
## Goal
|
||||
<!-- Was ist das Outcome? -->
|
||||
|
||||
## Context
|
||||
<!-- Warum brauchen wir das? Wer nutzt es? -->
|
||||
|
||||
## Scope
|
||||
- In:
|
||||
- [ ]
|
||||
- Out:
|
||||
- [ ]
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] ...
|
||||
- [ ] ...
|
||||
- [ ] ...
|
||||
|
||||
## Spec (SDD)
|
||||
- [ ] `specs/<NNN>-<feature>/plan.md`
|
||||
- [ ] `specs/<NNN>-<feature>/tasks.md`
|
||||
- [ ] `specs/<NNN>-<feature>/spec.md`
|
||||
|
||||
## Risks / Safety (Intune)
|
||||
- [ ] Dry-run/Preview möglich?
|
||||
- [ ] Audit Log Einträge nötig?
|
||||
- [ ] Confirmations / RBAC nötig?
|
||||
|
||||
## Implementation Notes (optional)
|
||||
<!-- Hinweise: relevante Ordner/Modelle/Services, APIs, UI Stellen -->
|
||||
|
||||
## Test Plan
|
||||
- [ ] Feature Test(s)
|
||||
- [ ] Failure path(s)
|
||||
- [ ] Manuelle Staging-Checks
|
||||
30
.gitea/pull_request_template.md
Normal file
30
.gitea/pull_request_template.md
Normal file
@ -0,0 +1,30 @@
|
||||
## Summary
|
||||
<!-- Kurz: Was ändert sich und warum? -->
|
||||
|
||||
## Spec-Driven Development (SDD)
|
||||
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
|
||||
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
|
||||
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
|
||||
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert
|
||||
|
||||
## Implementation
|
||||
- [ ] Implementierung entspricht der Spec
|
||||
- [ ] Edge cases / Fehlerfälle berücksichtigt
|
||||
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes
|
||||
|
||||
## Tests
|
||||
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
|
||||
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)
|
||||
|
||||
## Migration / Config / Ops (falls relevant)
|
||||
- [ ] Migration(en) enthalten und getestet
|
||||
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
|
||||
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
|
||||
- [ ] Queue/cron/storage Auswirkungen geprüft
|
||||
|
||||
## UI (Filament/Livewire) (falls relevant)
|
||||
- [ ] UI-Flows geprüft
|
||||
- [ ] Screenshots/Notizen hinzugefügt
|
||||
|
||||
## Notes
|
||||
<!-- Links, Screenshots, Follow-ups, offene Punkte -->
|
||||
@ -24,7 +24,7 @@ ## Completed Workstreams (no new action needed)
|
||||
- **US1 Inventory (Phase 3)**: Filament policy listing with type/category/platform filters; tenant-scoped.
|
||||
- **US2 Backups (Phase 4)**: Backup sets/items in JSONB, immutable snapshots, audit logging, relation manager UX for attaching policies, soft-delete rules with restore-run guard.
|
||||
- **US3 Versions/Diffs (Phase 5)**: Version capture, timelines, human+JSON diffs, soft-deletes with audit.
|
||||
- **US4 Restore (Phase 6)**: Preview, selective execution, conflict warnings, per-type restore level (enabled vs preview-only), PowerShell decode/encode respected, audit of outcomes; settings catalog fallback creates a new policy when the settings endpoint is unsupported, recording the new policy id and a manual cleanup warning.
|
||||
- **US4 Restore (Phase 6)**: Preview, selective execution, conflict warnings, per-type restore level (enabled vs preview-only), PowerShell decode/encode respected, audit of outcomes; settings catalog fallback creates a new policy when the settings endpoint is unsupported, retrying metadata-only creation if settings are not accepted, recording the new policy id and manual warnings.
|
||||
- **US6 Tenant Setup & Highlander (Phases 8 & 12)**: Tenant CRUD/verify, INTUNE_TENANT_ID override, `is_current` unique enforcement, “Make current” action, block deactivated tenants.
|
||||
- **US6 Permissions/Health (Phase 9)**: Required permissions list, compare/check service, Verify action updates status and audit, permissions panel in Tenant detail.
|
||||
- **US1b Settings Display (Phase 13)**: PolicyNormalizer + SnapshotValidator, warnings for malformed snapshots, normalized settings and pretty JSON on policy/version detail, list badges, README section.
|
||||
|
||||
@ -362,7 +362,7 @@ ### Functional Requirements
|
||||
- **FR-020**: For PowerShell script objects (`deviceManagementScript` in `scope.supported_types`), the `scriptContent` MUST be base64-decoded when stored in backups/versions for readability/diffing and encoded again when sent back to Graph during restore.
|
||||
- **FR-021**: Restore behavior MUST follow the per-type configuration in `scope.restore_matrix`: `backup` determines full vs metadata-only snapshots; `restore` determines whether automated restore is enabled or preview-only; `risk` informs warning/confirmation UX.
|
||||
- **FR-022**: For high-risk types with `restore: preview-only` in `scope.restore_matrix` (e.g., `conditionalAccessPolicy`, `enrollmentRestriction`), TenantPilot MUST provide full backups, version history, and diffs plus detailed restore previews, but MUST NOT expose direct Graph apply actions; restore is manual, guided by the preview.
|
||||
- **FR-036**: When `settingsCatalogPolicy` settings apply fails because the Graph settings endpoint is unsupported (route missing / method not allowed), the system MUST attempt a safe fallback by creating a new policy from the snapshot (including settings), record the new policy id, and mark the restore item as partial with a manual cleanup warning.
|
||||
- **FR-036**: When `settingsCatalogPolicy` settings apply fails because the Graph settings endpoint is unsupported (route missing / method not allowed), the system MUST attempt a safe fallback by creating a new policy from the snapshot and record the new policy id. If creating with settings is not supported, the system MUST retry with a metadata-only payload, mark the restore item as partial, and surface a manual settings-apply warning.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
|
||||
@ -710,12 +710,12 @@ ### Implementation
|
||||
2. **Sanitizer:** In `GraphContractRegistry` allow and preserve `@odata.type` inside `settingInstance` and nested children (recursively); continue to strip read-only/meta fields and `id`.
|
||||
3. **RestoreService:** Build `settingsPayload = sanitizeSettingsApplyPayload(snapshot['settings'])` and `POST` to the contract path; on failure mark item `manual_required` and persist Graph meta (`request_id`, `client_request_id`, error message).
|
||||
4. **UI:** RestoreRun Results view shows clear admin message when `manual_required` due to settings_apply, including request ids.
|
||||
5. **Fallback create:** If the settings apply call fails with route missing / method not allowed, create a new Settings Catalog policy via `POST deviceManagement/configurationPolicies` using a sanitized payload that includes the settings. Record the new policy id in restore results and mark the item as partial with a manual cleanup warning.
|
||||
5. **Fallback create:** If the settings apply call fails with route missing / method not allowed, create a new Settings Catalog policy via `POST deviceManagement/configurationPolicies` using a sanitized payload that includes the settings. If Graph returns `NotSupported`, retry with a metadata-only payload (no settings) and mark the item as partial with a manual settings apply warning. Record the new policy id in restore results.
|
||||
|
||||
### Tests (Pest)
|
||||
- Unit: `tests/Unit/GraphContractRegistrySettingsApplySanitizerTest.php` (preserve `@odata.type`, strip ids)
|
||||
- Feature: `tests/Feature/Filament/SettingsCatalogRestoreApplySettingsTest.php` (mock Graph, assert POST body includes `@odata.type` and success/failure flows)
|
||||
- Feature: add a restore test that simulates a settings apply route-missing error and verifies fallback policy creation + new policy id recorded.
|
||||
- Feature: add a restore test that simulates a settings apply route-missing error and verifies fallback policy creation + new policy id recorded, including metadata-only retry when create returns `NotSupported`.
|
||||
|
||||
### Verification
|
||||
- `./vendor/bin/pest tests/Unit/GraphContractRegistrySettingsApplySanitizerTest.php`
|
||||
|
||||
28
Agents.md
28
Agents.md
@ -33,6 +33,33 @@ ## Workflow (Spec Kit)
|
||||
|
||||
If requirements change during implementation, update spec/plan before continuing.
|
||||
|
||||
## Workflow (SDD in diesem Repo)
|
||||
|
||||
### Branching
|
||||
- Default / Integrations-Branch: `dev`
|
||||
- Neue Arbeit läuft über Feature-Branches von `dev`:
|
||||
- `feat/<NNN>-<slug>` (Code + Spec im selben PR)
|
||||
- optional: `spec/<NNN>-<slug>` (nur wenn wir Specs getrennt reviewen wollen)
|
||||
|
||||
### Wo liegen Specs?
|
||||
- `.specify/` enthält SpecKit Tooling und die Constitution (Prozessregeln).
|
||||
- Feature-Specs liegen **immer** im Repo unter:
|
||||
- `specs/<NNN>-<slug>/plan.md`
|
||||
- `specs/<NNN>-<slug>/tasks.md`
|
||||
- `specs/<NNN>-<slug>/spec.md`
|
||||
- `specs/` muss im `dev`-Branch immer existieren (Baseline).
|
||||
|
||||
### Variante B Standard (Spec + Code in einem PR)
|
||||
1) Branch von `dev` erstellen: `feat/<NNN>-<slug>`
|
||||
2) Zuerst Specs erstellen/aktualisieren → erster Commit (`spec:`)
|
||||
3) Dann implementieren → weitere Commits (`feat:`, `fix:`, `test:`)
|
||||
4) PR/MR: `feat/...` → `dev`
|
||||
5) Merge nach `dev` (empfohlen: Squash)
|
||||
|
||||
### Gate-Regel
|
||||
- Wenn Code geändert wird (z.B. `app/`, `config/`, `database/`, `resources/`),
|
||||
muss der PR auch `specs/<NNN>-<slug>/` enthalten oder aktualisieren.
|
||||
|
||||
## Multi-Agent Coordination
|
||||
|
||||
**Problem:** Multiple AI agents working simultaneously on the same branch can create conflicts and confusion.
|
||||
@ -120,7 +147,6 @@ # Reset to before the conflict
|
||||
# Or stash conflicting changes
|
||||
git stash push -m "conflicting-agent-work-$(date +%s)"
|
||||
```
|
||||
|
||||
## Architecture Assumptions
|
||||
- Backend: Laravel (latest stable)
|
||||
- Admin UI: Filament
|
||||
|
||||
@ -52,25 +52,34 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
// For Settings Catalog policies: Tabs with Settings table + JSON viewer
|
||||
Tabs::make('policy_content')
|
||||
->activeTab(1)
|
||||
->persistTabInQueryString()
|
||||
->tabs([
|
||||
Tab::make('General')
|
||||
->id('general')
|
||||
->schema([
|
||||
ViewEntry::make('policy_general')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.policy-general')
|
||||
->state(function (Policy $record) {
|
||||
$normalized = static::normalizedPolicyState($record);
|
||||
$split = static::splitGeneralBlock($normalized);
|
||||
|
||||
return $split['general'];
|
||||
}),
|
||||
])
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
Tab::make('Settings')
|
||||
->id('settings')
|
||||
->schema([
|
||||
ViewEntry::make('settings_catalog')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (Policy $record) {
|
||||
$snapshot = static::latestSnapshot($record);
|
||||
$normalized = static::normalizedPolicyState($record);
|
||||
$split = static::splitGeneralBlock($normalized);
|
||||
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
$snapshot,
|
||||
$record->policy_type,
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'policy';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
|
||||
return $normalized;
|
||||
return $split['normalized'];
|
||||
})
|
||||
->visible(fn (Policy $record) => $record->policy_type === 'settingsCatalogPolicy' &&
|
||||
$record->versions()->exists()
|
||||
@ -80,15 +89,10 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
->state(function (Policy $record) {
|
||||
$snapshot = static::latestSnapshot($record);
|
||||
$normalized = static::normalizedPolicyState($record);
|
||||
$split = static::splitGeneralBlock($normalized);
|
||||
|
||||
$normalizer = app(PolicyNormalizer::class);
|
||||
|
||||
return $normalizer->normalize(
|
||||
$snapshot,
|
||||
$record->policy_type,
|
||||
$record->platform
|
||||
);
|
||||
return $split['normalized'];
|
||||
})
|
||||
->visible(fn (Policy $record) => $record->policy_type !== 'settingsCatalogPolicy' &&
|
||||
$record->versions()->exists()
|
||||
@ -101,6 +105,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
||||
]),
|
||||
Tab::make('JSON')
|
||||
->id('json')
|
||||
->schema([
|
||||
ViewEntry::make('snapshot_json')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
@ -331,6 +336,71 @@ private static function latestSnapshot(Policy $record): array
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizedPolicyState(Policy $record): array
|
||||
{
|
||||
$cacheKey = 'tenantpilot.normalizedPolicyState.'.(string) $record->getKey();
|
||||
$request = request();
|
||||
|
||||
if ($request->attributes->has($cacheKey)) {
|
||||
$cached = $request->attributes->get($cacheKey);
|
||||
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$snapshot = static::latestSnapshot($record);
|
||||
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
$snapshot,
|
||||
$record->policy_type,
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'policy';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
|
||||
$request->attributes->set($cacheKey, $normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{settings?: array<int, array<string, mixed>>} $normalized
|
||||
* @return array{normalized: array<string, mixed>, general: ?array<string, mixed>}
|
||||
*/
|
||||
private static function splitGeneralBlock(array $normalized): array
|
||||
{
|
||||
$general = null;
|
||||
$filtered = [];
|
||||
|
||||
foreach ($normalized['settings'] ?? [] as $block) {
|
||||
if (! is_array($block)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = $block['title'] ?? null;
|
||||
|
||||
if (is_string($title) && strtolower($title) === 'general') {
|
||||
$general = $block;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = $block;
|
||||
}
|
||||
|
||||
$normalized['settings'] = $filtered;
|
||||
|
||||
return [
|
||||
'normalized' => $normalized,
|
||||
'general' => $general,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,string>|array<string,mixed>
|
||||
*/
|
||||
|
||||
@ -23,7 +23,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
$panel = $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
@ -55,5 +55,11 @@ public function panel(Panel $panel): Panel
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||
}
|
||||
|
||||
return $panel;
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,6 +150,7 @@ public function execute(
|
||||
$settings = [];
|
||||
$resultReason = null;
|
||||
$createdPolicyId = null;
|
||||
$createdPolicyMode = null;
|
||||
|
||||
if ($item->policy_type === 'settingsCatalogPolicy') {
|
||||
$settings = $this->extractSettingsCatalogSettings($originalPayload);
|
||||
@ -182,11 +183,26 @@ public function execute(
|
||||
|
||||
if ($createOutcome['success']) {
|
||||
$createdPolicyId = $createOutcome['policy_id'];
|
||||
$itemStatus = 'partial';
|
||||
$resultReason = 'Settings endpoint unsupported; created new policy. Manual cleanup required.';
|
||||
$createdPolicyMode = $createOutcome['mode'] ?? null;
|
||||
$mode = $createOutcome['mode'] ?? 'settings';
|
||||
|
||||
// When settings are included in CREATE, mark as applied instead of partial
|
||||
$itemStatus = $mode === 'settings' ? 'applied' : 'partial';
|
||||
|
||||
$resultReason = $mode === 'metadata_only'
|
||||
? 'Settings endpoint unsupported; created metadata-only policy. Manual settings apply required.'
|
||||
: 'Settings endpoint unsupported; created new policy with settings. Manual cleanup required.';
|
||||
|
||||
if ($settingsApply !== null && $createdPolicyId) {
|
||||
$settingsApply['created_policy_id'] = $createdPolicyId;
|
||||
$settingsApply['created_policy_mode'] = $mode;
|
||||
|
||||
// Update statistics when settings were included in CREATE
|
||||
if ($mode === 'settings') {
|
||||
$settingsApply['applied'] = $settingsApply['total'] ?? count($settings);
|
||||
$settingsApply['manual_required'] = 0;
|
||||
$settingsApply['issues'] = [];
|
||||
}
|
||||
}
|
||||
} elseif ($settingsApply !== null && $createOutcome['response']) {
|
||||
$settingsApply['issues'][] = [
|
||||
@ -259,6 +275,10 @@ public function execute(
|
||||
$result['created_policy_id'] = $createdPolicyId;
|
||||
}
|
||||
|
||||
if ($createdPolicyMode) {
|
||||
$result['created_policy_mode'] = $createdPolicyMode;
|
||||
}
|
||||
|
||||
if ($resultReason !== null) {
|
||||
$result['reason'] = $resultReason;
|
||||
} elseif ($itemStatus !== 'applied') {
|
||||
@ -612,7 +632,7 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success:bool,policy_id:?string,response:?object}
|
||||
* @return array{success:bool,policy_id:?string,response:?object,mode:string}
|
||||
*/
|
||||
private function createSettingsCatalogPolicy(
|
||||
array $originalPayload,
|
||||
@ -629,10 +649,11 @@ private function createSettingsCatalogPolicy(
|
||||
'success' => false,
|
||||
'policy_id' => null,
|
||||
'response' => null,
|
||||
'mode' => 'failed',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = $this->buildSettingsCatalogCreatePayload($originalPayload, $sanitizedSettings, $fallbackName);
|
||||
$payload = $this->buildSettingsCatalogCreatePayload($originalPayload, $sanitizedSettings, $fallbackName, true);
|
||||
|
||||
$this->graphLogger->logRequest('create_settings_catalog_policy', $context + [
|
||||
'endpoint' => $resource,
|
||||
@ -648,24 +669,67 @@ private function createSettingsCatalogPolicy(
|
||||
'settings_count' => count($sanitizedSettings),
|
||||
]);
|
||||
|
||||
$policyId = null;
|
||||
if ($response->successful() && isset($response->data['id']) && is_string($response->data['id'])) {
|
||||
$policyId = $response->data['id'];
|
||||
$policyId = $this->extractCreatedPolicyId($response);
|
||||
$mode = 'settings';
|
||||
|
||||
if ($response->failed() && $this->shouldRetrySettingsCatalogCreateWithoutSettings($response)) {
|
||||
$fallbackPayload = $this->buildSettingsCatalogCreatePayload($originalPayload, $sanitizedSettings, $fallbackName, false);
|
||||
|
||||
$this->graphLogger->logRequest('create_settings_catalog_policy_fallback', $context + [
|
||||
'endpoint' => $resource,
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
$response = $this->graphClient->request('POST', $resource, ['json' => $fallbackPayload] + Arr::except($graphOptions, ['platform']));
|
||||
|
||||
$this->graphLogger->logResponse('create_settings_catalog_policy_fallback', $response, $context + [
|
||||
'endpoint' => $resource,
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
$policyId = $this->extractCreatedPolicyId($response);
|
||||
$mode = 'metadata_only';
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $response->successful(),
|
||||
'policy_id' => $policyId,
|
||||
'response' => $response,
|
||||
'mode' => $mode,
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool
|
||||
{
|
||||
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
|
||||
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
|
||||
|
||||
if ($code === 'notsupported' || str_contains($code, 'notsupported')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains($message, 'not supported');
|
||||
}
|
||||
|
||||
private function extractCreatedPolicyId(object $response): ?string
|
||||
{
|
||||
if ($response->successful() && isset($response->data['id']) && is_string($response->data['id'])) {
|
||||
return $response->data['id'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSettingsCatalogCreatePayload(array $originalPayload, array $settings, string $fallbackName): array
|
||||
{
|
||||
private function buildSettingsCatalogCreatePayload(
|
||||
array $originalPayload,
|
||||
array $settings,
|
||||
string $fallbackName,
|
||||
bool $includeSettings,
|
||||
): array {
|
||||
$payload = [];
|
||||
$name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']);
|
||||
|
||||
@ -676,14 +740,22 @@ private function buildSettingsCatalogCreatePayload(array $originalPayload, array
|
||||
$payload['description'] = $description;
|
||||
}
|
||||
|
||||
// Platforms and technologies must be singular strings for CREATE (not arrays)
|
||||
// Graph API inconsistency: GET returns arrays, but POST expects strings
|
||||
$platforms = $this->resolvePayloadArray($originalPayload, ['platforms', 'Platforms']);
|
||||
if ($platforms !== null) {
|
||||
$payload['platforms'] = array_values($platforms);
|
||||
if ($platforms !== null && $platforms !== []) {
|
||||
$payload['platforms'] = is_array($platforms) ? $platforms[0] : $platforms;
|
||||
} elseif ($platforms === null) {
|
||||
// Fallback: extract from policy_type or default to windows10
|
||||
$payload['platforms'] = 'windows10';
|
||||
}
|
||||
|
||||
$technologies = $this->resolvePayloadArray($originalPayload, ['technologies', 'Technologies']);
|
||||
if ($technologies !== null) {
|
||||
$payload['technologies'] = array_values($technologies);
|
||||
if ($technologies !== null && $technologies !== []) {
|
||||
$payload['technologies'] = is_array($technologies) ? $technologies[0] : $technologies;
|
||||
} elseif ($technologies === null) {
|
||||
// Default to mdm if not present
|
||||
$payload['technologies'] = 'mdm';
|
||||
}
|
||||
|
||||
$roleScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']);
|
||||
@ -696,7 +768,7 @@ private function buildSettingsCatalogCreatePayload(array $originalPayload, array
|
||||
$payload['templateReference'] = $this->stripOdataAndReadOnly($templateReference);
|
||||
}
|
||||
|
||||
if ($settings !== []) {
|
||||
if ($includeSettings && $settings !== []) {
|
||||
$payload['settings'] = $settings;
|
||||
}
|
||||
|
||||
|
||||
826
package-lock.json
generated
826
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
5
resources/css/filament/admin/theme.css
Normal file
5
resources/css/filament/admin/theme.css
Normal file
@ -0,0 +1,5 @@
|
||||
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||
|
||||
@source '../../../../app/Filament/**/*';
|
||||
@source '../../../../resources/views/filament/**/*.blade.php';
|
||||
@source '../../../../resources/views/livewire/**/*.blade.php';
|
||||
@ -25,8 +25,18 @@
|
||||
@endif
|
||||
|
||||
@if (! empty($settingsTableRows))
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="text-sm font-semibold text-gray-800">{{ is_array($settingsTable) ? ($settingsTable['title'] ?? 'Settings') : 'Settings' }}</div>
|
||||
@php
|
||||
$settingsTableTitle = is_array($settingsTable) ? ($settingsTable['title'] ?? null) : null;
|
||||
$shouldShowTitle = is_string($settingsTableTitle)
|
||||
&& $settingsTableTitle !== ''
|
||||
&& ! ($context === 'policy' && strtolower($settingsTableTitle) === 'settings');
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2">
|
||||
@if ($shouldShowTitle)
|
||||
<div class="text-sm font-semibold text-gray-800">{{ $settingsTableTitle }}</div>
|
||||
@endif
|
||||
|
||||
<livewire:settings-catalog-settings-table
|
||||
:settings-rows="$settingsTableRows"
|
||||
:context="$context"
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
@php
|
||||
$general = $getState();
|
||||
$entries = is_array($general) ? ($general['entries'] ?? []) : [];
|
||||
$cards = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (! is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $entry['key'] ?? null;
|
||||
$value = $entry['value'] ?? null;
|
||||
$decoded = null;
|
||||
|
||||
if (is_string($value)) {
|
||||
$trimmed = trim($value);
|
||||
|
||||
if ($trimmed !== '' && (str_starts_with($trimmed, '{') || str_starts_with($trimmed, '['))) {
|
||||
$decodedValue = json_decode($trimmed, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$decoded = $decodedValue;
|
||||
$value = $decodedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$isEmpty = $value === null
|
||||
|| $value === ''
|
||||
|| $value === '-'
|
||||
|| (is_array($value) && $value === []);
|
||||
|
||||
if ($isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($key) && $key !== '' ? $key : 'Field';
|
||||
|
||||
$cards[] = [
|
||||
'key' => $label,
|
||||
'key_lower' => strtolower($label),
|
||||
'value' => $value,
|
||||
'decoded' => $decoded,
|
||||
];
|
||||
}
|
||||
|
||||
$toneMap = [
|
||||
'name' => ['icon' => 'heroicon-o-tag', 'ring' => 'ring-amber-200/70 dark:ring-amber-800/60', 'tone' => 'amber'],
|
||||
'platform' => ['icon' => 'heroicon-o-computer-desktop', 'ring' => 'ring-sky-200/70 dark:ring-sky-800/60', 'tone' => 'sky'],
|
||||
'settings' => ['icon' => 'heroicon-o-adjustments-horizontal', 'ring' => 'ring-emerald-200/70 dark:ring-emerald-800/60', 'tone' => 'emerald'],
|
||||
'template' => ['icon' => 'heroicon-o-rectangle-stack', 'ring' => 'ring-rose-200/70 dark:ring-rose-800/60', 'tone' => 'rose'],
|
||||
'technology' => ['icon' => 'heroicon-o-cpu-chip', 'ring' => 'ring-teal-200/70 dark:ring-teal-800/60', 'tone' => 'teal'],
|
||||
'default' => ['icon' => 'heroicon-o-document-text', 'ring' => 'ring-gray-200/70 dark:ring-gray-700/60', 'tone' => 'slate'],
|
||||
];
|
||||
|
||||
$toneClasses = [
|
||||
'amber' => 'bg-amber-100/80 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200',
|
||||
'sky' => 'bg-sky-100/80 text-sky-700 dark:bg-sky-900/40 dark:text-sky-200',
|
||||
'emerald' => 'bg-emerald-100/80 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200',
|
||||
'rose' => 'bg-rose-100/80 text-rose-700 dark:bg-rose-900/40 dark:text-rose-200',
|
||||
'teal' => 'bg-teal-100/80 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200',
|
||||
'slate' => 'bg-slate-100/80 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200',
|
||||
];
|
||||
@endphp
|
||||
|
||||
@if (empty($cards))
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">No general metadata available.</p>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($cards as $entry)
|
||||
@php
|
||||
$keyLower = $entry['key_lower'] ?? '';
|
||||
$value = $entry['value'] ?? null;
|
||||
$isPlatform = str_contains($keyLower, 'platform');
|
||||
$toneKey = match (true) {
|
||||
str_contains($keyLower, 'name') => 'name',
|
||||
str_contains($keyLower, 'platform') => 'platform',
|
||||
str_contains($keyLower, 'setting') => 'settings',
|
||||
str_contains($keyLower, 'template') => 'template',
|
||||
str_contains($keyLower, 'technology') => 'technology',
|
||||
default => 'default',
|
||||
};
|
||||
$tone = $toneMap[$toneKey] ?? $toneMap['default'];
|
||||
$toneClass = $toneClasses[$tone['tone'] ?? 'slate'] ?? $toneClasses['slate'];
|
||||
|
||||
$isJsonValue = is_array($value) && ! (array_is_list($value) && array_reduce($value, fn ($carry, $item) => $carry && is_scalar($item), true));
|
||||
$isListValue = is_array($value) && array_is_list($value) && array_reduce($value, fn ($carry, $item) => $carry && is_scalar($item), true);
|
||||
$isBooleanValue = is_bool($value);
|
||||
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true);
|
||||
$isNumericValue = is_numeric($value);
|
||||
@endphp
|
||||
|
||||
<div class="tp-policy-general-card group relative overflow-hidden rounded-xl border border-gray-200/70 bg-white p-4 shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-gray-300/70 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900 dark:hover:border-gray-600">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg ring-1 {{ $tone['ring'] ?? '' }} {{ $toneClass }}">
|
||||
<x-filament::icon icon="{{ $tone['icon'] ?? 'heroicon-o-document-text' }}" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<dt class="text-xs font-semibold tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['key'] ?? '-' }}
|
||||
</dt>
|
||||
<dd class="mt-2 text-left">
|
||||
@if ($isListValue)
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($value as $item)
|
||||
<x-filament::badge :color="$isPlatform ? 'info' : 'gray'" size="sm">
|
||||
{{ $item }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($isJsonValue)
|
||||
<pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT) }}</pre>
|
||||
@elseif ($isBooleanValue || $isBooleanString)
|
||||
@php
|
||||
$boolValue = $isBooleanValue
|
||||
? $value
|
||||
: in_array(strtolower($value), ['true', 'enabled'], true);
|
||||
$boolLabel = $boolValue ? 'Enabled' : 'Disabled';
|
||||
@endphp
|
||||
<x-filament::badge :color="$boolValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $boolLabel }}
|
||||
</x-filament::badge>
|
||||
@elseif ($isNumericValue)
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white tabular-nums">
|
||||
{{ number_format((float) $value) }}
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap break-words text-left">
|
||||
{{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT) }}
|
||||
</div>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@ -49,8 +49,14 @@
|
||||
@endif
|
||||
|
||||
@if (! empty($item['created_policy_id']))
|
||||
@php
|
||||
$createdMode = $item['created_policy_mode'] ?? null;
|
||||
$createdMessage = $createdMode === 'metadata_only'
|
||||
? 'New policy created (metadata only). Apply settings manually.'
|
||||
: 'New policy created (manual cleanup required).';
|
||||
@endphp
|
||||
<div class="mt-2 text-xs text-amber-800">
|
||||
New policy created: {{ $item['created_policy_id'] }} (manual cleanup required).
|
||||
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
// Normalize payload to array for the JSON viewer
|
||||
$payloadArray = is_string($payload) ? (json_decode($payload, true) ?? []) : ($payload ?? []);
|
||||
$rawJson = json_encode($payloadArray, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// Provide the small set of helpers the pepperfm json view expects
|
||||
$getState = fn () => $payloadArray;
|
||||
@ -17,10 +18,51 @@
|
||||
$getRenderMode = fn () => \PepperFM\FilamentJson\Enums\RenderModeEnum::Tree;
|
||||
$getInitiallyCollapsed = fn () => 1;
|
||||
$getExpandAllToggle = fn () => false;
|
||||
$getCopyJsonAction = fn () => true;
|
||||
$getCopyJsonAction = fn () => false;
|
||||
$getMaxDepth = fn () => 3;
|
||||
$applyLimit = fn ($v) => $v;
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="space-y-2"
|
||||
x-data="{
|
||||
text: @js($rawJson),
|
||||
async copyJson() {
|
||||
try {
|
||||
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
|
||||
await navigator.clipboard.writeText(this.text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = this.text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.inset = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
}
|
||||
|
||||
new FilamentNotification()
|
||||
.title('Copied!')
|
||||
.icon('heroicon-o-clipboard-document-check')
|
||||
.success()
|
||||
.send();
|
||||
} catch (e) {
|
||||
new FilamentNotification()
|
||||
.title('Copy failed!')
|
||||
.danger()
|
||||
.send();
|
||||
}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-end">
|
||||
<x-filament::button size="xs" color="gray" x-on:click="copyJson()">
|
||||
Copy JSON
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
{{-- Render pepperfm filament-json viewer --}}
|
||||
@include('filament-json::json')
|
||||
</div>
|
||||
|
||||
0
specs/.gitkeep
Normal file
0
specs/.gitkeep
Normal file
469
specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md
Normal file
469
specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md
Normal file
@ -0,0 +1,469 @@
|
||||
# Feature 185: Implementation Status Report
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Status**: ✅ **Core Implementation Complete** (Phases 1-5)
|
||||
**Date**: 2025-12-13
|
||||
**Remaining Work**: Testing & Manual Verification (Phases 6-7)
|
||||
|
||||
## Implementation Progress
|
||||
|
||||
### ✅ Completed Phases (1-5)
|
||||
|
||||
#### Phase 1: Database Foundation
|
||||
- ✅ T001: Migration created and applied successfully (73.61ms)
|
||||
- ✅ T002: SettingsCatalogDefinition model with helper methods
|
||||
- **Result**: `settings_catalog_definitions` table exists with GIN index on JSONB
|
||||
|
||||
#### Phase 2: Definition Resolver Service
|
||||
- ✅ T003-T007: Complete SettingsCatalogDefinitionResolver service
|
||||
- **Features**:
|
||||
- 3-tier caching: Memory → Database (30 days) → Graph API
|
||||
- Batch resolution with `$filter=id in (...)` optimization
|
||||
- Non-blocking cache warming with error handling
|
||||
- Graceful fallback with prettified definition IDs
|
||||
- **File**: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` (267 lines)
|
||||
|
||||
#### Phase 3: Snapshot Enrichment
|
||||
- ✅ T008-T010: Extended PolicySnapshotService
|
||||
- **Features**:
|
||||
- Extracts definition IDs from settings (including nested children)
|
||||
- Calls warmCache() after settings hydration
|
||||
- Adds metadata: `definition_count`, `definitions_cached`
|
||||
- **File**: `app/Services/Intune/PolicySnapshotService.php` (extended)
|
||||
|
||||
#### Phase 4: Normalizer Enhancement
|
||||
- ✅ T011-T014: Extended PolicyNormalizer
|
||||
- **Features**:
|
||||
- `normalizeSettingsCatalogGrouped()` main method
|
||||
- Value formatting: bool → badges, int → formatted, string → truncated
|
||||
- Grouping by categoryId with fallback to definition ID segments
|
||||
- Recursive flattening of nested group settings
|
||||
- Alphabetical sorting of groups
|
||||
- **File**: `app/Services/Intune/PolicyNormalizer.php` (extended with 8 new methods)
|
||||
|
||||
#### Phase 5: UI Implementation
|
||||
- ✅ T015-T022: Complete Settings tab with grouped accordion view
|
||||
- **Features**:
|
||||
- Filament Section components for collapsible groups
|
||||
- First group expanded by default
|
||||
- Setting rows with labels, formatted values, help text
|
||||
- Alpine.js copy buttons with clipboard API
|
||||
- Client-side search filtering
|
||||
- Empty states and fallback warnings
|
||||
- Dark mode support
|
||||
- **Files**:
|
||||
- `resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php` (~130 lines)
|
||||
- `app/Filament/Resources/PolicyResource.php` (Settings tab extended)
|
||||
|
||||
### ⏳ Pending Phases (6-7)
|
||||
|
||||
#### Phase 6: Manual Verification (T023-T025)
|
||||
- [ ] T023: Verify JSON tab still works
|
||||
- [ ] T024: Verify fallback message for uncached definitions
|
||||
- [ ] T025: Ensure JSON viewer scoped to Policy View only
|
||||
|
||||
**Estimated Time**: ~15 minutes
|
||||
**Action Required**: Navigate to `/admin/policies/{id}` for Settings Catalog policy
|
||||
|
||||
#### Phase 7: Testing & Validation (T026-T042)
|
||||
- [ ] T026-T031: Unit tests (SettingsCatalogDefinitionResolverTest, PolicyNormalizerSettingsCatalogTest)
|
||||
- [ ] T032-T037: Feature tests (PolicyViewSettingsCatalogReadableTest)
|
||||
- [ ] T038-T039: Pest suite execution, Pint formatting
|
||||
- [ ] T040-T042: Git review, migration check, manual QA walkthrough
|
||||
|
||||
**Estimated Time**: ~4-5 hours
|
||||
**Action Required**: Write comprehensive test coverage
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Verification
|
||||
|
||||
### ✅ Laravel Pint
|
||||
- **Status**: PASS - 32 files formatted
|
||||
- **Command**: `./vendor/bin/sail pint --dirty`
|
||||
- **Result**: All code compliant with Laravel coding standards
|
||||
|
||||
### ✅ Cache Management
|
||||
- **Command**: `./vendor/bin/sail artisan optimize:clear`
|
||||
- **Result**: All caches cleared (config, views, routes, Blade, Filament)
|
||||
|
||||
### ✅ Database Migration
|
||||
- **Command**: `./vendor/bin/sail artisan migrate`
|
||||
- **Result**: `settings_catalog_definitions` table exists
|
||||
- **Verification**: `Schema::hasTable('settings_catalog_definitions')` returns `true`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Service Layer
|
||||
|
||||
```
|
||||
PolicySnapshotService
|
||||
↓ (extracts definition IDs)
|
||||
SettingsCatalogDefinitionResolver
|
||||
↓ (resolves definitions)
|
||||
PolicyNormalizer
|
||||
↓ (groups & formats)
|
||||
PolicyResource (Filament)
|
||||
↓ (renders)
|
||||
settings-catalog-grouped.blade.php
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
```
|
||||
Request
|
||||
↓
|
||||
Memory Cache (Laravel Cache, request-level)
|
||||
↓ (miss)
|
||||
Database Cache (30 days TTL)
|
||||
↓ (miss)
|
||||
Graph API (/deviceManagement/configurationSettings)
|
||||
↓ (store)
|
||||
Database + Memory
|
||||
↓ (fallback on Graph failure)
|
||||
Prettified Definition ID
|
||||
```
|
||||
|
||||
### UI Flow
|
||||
|
||||
```
|
||||
Policy View (Filament)
|
||||
↓
|
||||
Tabs: Settings | JSON
|
||||
↓ (Settings tab)
|
||||
Check metadata.definitions_cached
|
||||
↓ (true)
|
||||
settings_grouped ViewEntry
|
||||
↓
|
||||
normalizeSettingsCatalogGrouped()
|
||||
↓
|
||||
Blade Component
|
||||
↓
|
||||
Accordion Groups (Filament Sections)
|
||||
↓
|
||||
Setting Rows (label, value, help text, copy button)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Created Files (5)
|
||||
|
||||
1. **database/migrations/2025_12_13_212126_create_settings_catalog_definitions_table.php**
|
||||
- Purpose: Cache setting definitions from Graph API
|
||||
- Schema: 9 columns + timestamps, GIN index on JSONB
|
||||
- Status: ✅ Applied (73.61ms)
|
||||
|
||||
2. **app/Models/SettingsCatalogDefinition.php**
|
||||
- Purpose: Eloquent model for cached definitions
|
||||
- Methods: `findByDefinitionId()`, `findByDefinitionIds()`
|
||||
- Status: ✅ Complete
|
||||
|
||||
3. **app/Services/Intune/SettingsCatalogDefinitionResolver.php**
|
||||
- Purpose: Fetch and cache definitions with 3-tier strategy
|
||||
- Lines: 267
|
||||
- Methods: `resolve()`, `resolveOne()`, `warmCache()`, `clearCache()`, `prettifyDefinitionId()`
|
||||
- Status: ✅ Complete with error handling
|
||||
|
||||
4. **resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php**
|
||||
- Purpose: Blade template for grouped settings accordion
|
||||
- Lines: ~130
|
||||
- Features: Alpine.js interactivity, Filament Sections, search filtering
|
||||
- Status: ✅ Complete with dark mode support
|
||||
|
||||
5. **specs/185-settings-catalog-readable/** (Directory with 3 files)
|
||||
- `spec.md` - Complete feature specification
|
||||
- `plan.md` - Implementation plan
|
||||
- `tasks.md` - 42 tasks with FR traceability
|
||||
- Status: ✅ Complete with implementation notes
|
||||
|
||||
### Modified Files (3)
|
||||
|
||||
1. **app/Services/Intune/PolicySnapshotService.php**
|
||||
- Changes: Added `SettingsCatalogDefinitionResolver` injection
|
||||
- New method: `extractDefinitionIds()` (recursive extraction)
|
||||
- Extended method: `hydrateSettingsCatalog()` (cache warming + metadata)
|
||||
- Status: ✅ Extended without breaking existing functionality
|
||||
|
||||
2. **app/Services/Intune/PolicyNormalizer.php**
|
||||
- Changes: Added `SettingsCatalogDefinitionResolver` injection
|
||||
- New methods: 8 methods (~200 lines)
|
||||
- `normalizeSettingsCatalogGrouped()` (main entry point)
|
||||
- `extractAllDefinitionIds()`, `flattenSettingsCatalogForGrouping()`
|
||||
- `formatSettingsCatalogValue()`, `groupSettingsByCategory()`
|
||||
- `extractCategoryFromDefinitionId()`, `formatCategoryTitle()`
|
||||
- Status: ✅ Extended with comprehensive formatting/grouping logic
|
||||
|
||||
3. **app/Filament/Resources/PolicyResource.php**
|
||||
- Changes: Extended Settings tab in `policy_content` Tabs
|
||||
- New entries:
|
||||
- `settings_grouped` ViewEntry (uses Blade component)
|
||||
- `definitions_not_cached` TextEntry (fallback message)
|
||||
- Conditional rendering: Grouped view only if `definitions_cached === true`
|
||||
- Status: ✅ Extended Settings tab, JSON tab preserved
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist (Pre-Testing)
|
||||
|
||||
### ✅ Code Quality
|
||||
- [X] Laravel Pint passed (32 files)
|
||||
- [X] All code formatted with PSR-12 conventions
|
||||
- [X] No Pint warnings or errors
|
||||
|
||||
### ✅ Database
|
||||
- [X] Migration applied successfully
|
||||
- [X] Table exists with correct schema
|
||||
- [X] Indexes created (definition_id unique, category_id, GIN on raw)
|
||||
|
||||
### ✅ Service Injection
|
||||
- [X] SettingsCatalogDefinitionResolver registered in service container
|
||||
- [X] PolicySnapshotService constructor updated
|
||||
- [X] PolicyNormalizer constructor updated
|
||||
- [X] Laravel auto-resolves dependencies
|
||||
|
||||
### ✅ Caching
|
||||
- [X] All caches cleared (config, views, routes, Blade, Filament)
|
||||
- [X] Blade component compiled
|
||||
- [X] Filament schema cache refreshed
|
||||
|
||||
### ✅ UI Integration
|
||||
- [X] Settings tab extended with grouped view
|
||||
- [X] JSON tab preserved from Feature 002
|
||||
- [X] Conditional rendering based on metadata
|
||||
- [X] Fallback message implemented
|
||||
|
||||
### ⏳ Manual Verification Pending
|
||||
- [ ] Navigate to Policy View for Settings Catalog policy
|
||||
- [ ] Verify accordion renders with groups
|
||||
- [ ] Verify display names shown (not raw definition IDs)
|
||||
- [ ] Verify values formatted (badges, numbers, truncated strings)
|
||||
- [ ] Test search filtering
|
||||
- [ ] Test copy buttons
|
||||
- [ ] Switch to JSON tab, verify snapshot renders
|
||||
- [ ] Test fallback for policy without cached definitions
|
||||
- [ ] Test dark mode toggle
|
||||
|
||||
### ⏳ Testing Pending
|
||||
- [ ] Unit tests written and passing
|
||||
- [ ] Feature tests written and passing
|
||||
- [ ] Performance benchmarks validated
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Priority Order)
|
||||
|
||||
### Immediate (Phase 6 - Manual Verification)
|
||||
|
||||
1. **Open Policy View** (5 min)
|
||||
- Navigate to `/admin/policies/{id}` for Settings Catalog policy
|
||||
- Verify page loads without errors
|
||||
- Check browser console for JavaScript errors
|
||||
|
||||
2. **Verify Tabs & Accordion** (5 min)
|
||||
- Confirm "Settings" and "JSON" tabs visible
|
||||
- Click Settings tab, verify accordion renders
|
||||
- Verify groups collapsible (first expanded by default)
|
||||
- Click JSON tab, verify snapshot renders with copy button
|
||||
|
||||
3. **Verify Display & Formatting** (5 min)
|
||||
- Check setting labels show display names (not `device_vendor_msft_...`)
|
||||
- Verify bool values show as "Enabled"/"Disabled" badges (green/gray)
|
||||
- Verify int values formatted with separators (e.g., "1,000")
|
||||
- Verify long strings truncated with "..." and copy button
|
||||
|
||||
4. **Test Search & Fallback** (5 min)
|
||||
- Type in search box (if visible), verify filtering works
|
||||
- Test copy buttons (long values)
|
||||
- Find policy WITHOUT cached definitions
|
||||
- Verify fallback message: "Definitions not yet cached..."
|
||||
- Verify JSON tab still accessible
|
||||
|
||||
**Total Estimated Time**: ~20 minutes
|
||||
|
||||
### Short-Term (Phase 7 - Unit Tests)
|
||||
|
||||
1. **Create Unit Tests** (2-3 hours)
|
||||
- `tests/Unit/SettingsCatalogDefinitionResolverTest.php`
|
||||
- Test `resolve()` with batch IDs
|
||||
- Test memory cache hit
|
||||
- Test database cache hit
|
||||
- Test Graph API fetch
|
||||
- Test fallback prettification
|
||||
- Test non-blocking warmCache()
|
||||
- `tests/Unit/PolicyNormalizerSettingsCatalogTest.php`
|
||||
- Test `normalizeSettingsCatalogGrouped()` output structure
|
||||
- Test value formatting (bool, int, string, choice)
|
||||
- Test grouping by categoryId
|
||||
- Test fallback grouping by definition ID segments
|
||||
- Test recursive definition ID extraction
|
||||
|
||||
2. **Create Feature Tests** (2 hours)
|
||||
- `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php`
|
||||
- Test Settings Catalog policy view shows tabs
|
||||
- Test Settings tab shows display names (not definition IDs)
|
||||
- Test values formatted correctly (badges, numbers, truncation)
|
||||
- Test search filters settings
|
||||
- Test fallback message when definitions not cached
|
||||
- Test JSON tab still accessible
|
||||
|
||||
3. **Run Test Suite** (15 min)
|
||||
- `./vendor/bin/sail artisan test --filter=SettingsCatalog`
|
||||
- Fix any failures
|
||||
- Verify all tests pass
|
||||
|
||||
**Total Estimated Time**: ~5 hours
|
||||
|
||||
### Medium-Term (Performance & Polish)
|
||||
|
||||
1. **Performance Testing** (1 hour)
|
||||
- Create test policy with 200+ settings
|
||||
- Measure render time (target: <2s)
|
||||
- Measure definition resolution time (target: <500ms for 50 cached)
|
||||
- Profile with Laravel Telescope or Debugbar
|
||||
|
||||
2. **Manual QA Walkthrough** (1 hour)
|
||||
- Test all user stories (US-UI-04, US-UI-05, US-UI-06)
|
||||
- Verify all success criteria (SC-001 to SC-010)
|
||||
- Test dark mode toggle
|
||||
- Test with different policy types
|
||||
- Document any issues or enhancements
|
||||
|
||||
**Total Estimated Time**: ~2 hours
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### ✅ Mitigated Risks
|
||||
|
||||
- **Graph API Rate Limiting**: Non-blocking cache warming prevents snapshot save failures
|
||||
- **Definition Schema Changes**: Raw JSONB storage allows future parsing updates
|
||||
- **Large Policy Rendering**: Accordion lazy-loading via Filament Sections
|
||||
- **Missing Definitions**: Multi-layer fallback (prettified IDs → warning badges → info messages)
|
||||
|
||||
### ⚠️ Outstanding Risks
|
||||
|
||||
- **Performance with 500+ Settings**: Not tested yet (Phase 7, T042)
|
||||
- **Graph API Downtime**: Cache helps, but first sync may fail (acceptable trade-off)
|
||||
- **Browser Compatibility**: Alpine.js clipboard API requires HTTPS (Dokploy provides SSL)
|
||||
|
||||
### ℹ️ Known Limitations
|
||||
|
||||
- **Search**: Client-side only (Blade-level filtering), no debouncing for large policies
|
||||
- **Value Expansion**: Long strings truncated, no inline expansion (copy button only)
|
||||
- **Nested Groups**: Flattened in UI, hierarchy not visually preserved
|
||||
|
||||
---
|
||||
|
||||
## Constitution Compliance
|
||||
|
||||
### ✅ Safety-First
|
||||
- Read-only feature, no edit capabilities
|
||||
- Graceful degradation at every layer
|
||||
- Non-blocking operations (warmCache)
|
||||
|
||||
### ✅ Immutable Versioning
|
||||
- Snapshot enrichment adds metadata only
|
||||
- No modification of existing snapshot data
|
||||
- Definition cache separate from policy snapshots
|
||||
|
||||
### ✅ Defensive Restore
|
||||
- Not applicable (read-only feature)
|
||||
|
||||
### ✅ Auditability
|
||||
- Raw JSON still accessible via JSON tab
|
||||
- Definition resolution logged via Laravel Log
|
||||
- Graph API calls auditable via GraphLogger
|
||||
|
||||
### ✅ Tenant-Aware
|
||||
- Resolver respects tenant scoping via GraphClient
|
||||
- Definitions scoped per tenant (via Graph API calls)
|
||||
|
||||
### ✅ Graph Abstraction
|
||||
- Uses existing GraphClientInterface (no direct MS Graph SDK calls)
|
||||
- Follows existing abstraction patterns
|
||||
|
||||
### ✅ Spec-Driven
|
||||
- Full spec + plan + tasks before implementation
|
||||
- FR→Task traceability maintained
|
||||
- Implementation notes added to tasks.md
|
||||
|
||||
---
|
||||
|
||||
## Deployment Readiness
|
||||
|
||||
### ✅ Local Development (Laravel Sail)
|
||||
- [X] Database migration applied
|
||||
- [X] Services registered in container
|
||||
- [X] Caches cleared
|
||||
- [X] Code formatted with Pint
|
||||
- [X] Table exists with data ready for seeding
|
||||
|
||||
### ⏳ Staging Deployment (Dokploy)
|
||||
- [ ] Run migrations: `php artisan migrate`
|
||||
- [ ] Clear caches: `php artisan optimize:clear`
|
||||
- [ ] Verify environment variables (none required for Feature 185)
|
||||
- [ ] Test with real Intune tenant data
|
||||
- [ ] Monitor Graph API rate limits
|
||||
|
||||
### ⏳ Production Deployment (Dokploy)
|
||||
- [ ] Complete staging validation
|
||||
- [ ] Feature flag enabled (if applicable)
|
||||
- [ ] Monitor performance metrics
|
||||
- [ ] Document rollback plan (drop table, revert code)
|
||||
|
||||
---
|
||||
|
||||
## Support Information
|
||||
|
||||
### Troubleshooting Guide
|
||||
|
||||
**Issue**: Settings tab shows raw definition IDs instead of display names
|
||||
- **Cause**: Definitions not cached yet
|
||||
- **Solution**: Wait for next policy sync (SyncPoliciesJob) or manually trigger sync
|
||||
|
||||
**Issue**: Accordion doesn't render, blank Settings tab
|
||||
- **Cause**: JavaScript error in Blade component
|
||||
- **Solution**: Check browser console for errors, verify Alpine.js loaded
|
||||
|
||||
**Issue**: "Definitions not cached" message persists
|
||||
- **Cause**: Graph API call failed during snapshot
|
||||
- **Solution**: Check logs for Graph API errors, verify permissions for `/deviceManagement/configurationSettings` endpoint
|
||||
|
||||
**Issue**: Performance slow with large policies
|
||||
- **Cause**: Too many settings rendered at once
|
||||
- **Solution**: Consider pagination or virtual scrolling (future enhancement)
|
||||
|
||||
### Maintenance Tasks
|
||||
|
||||
- **Cache Clearing**: Run `php artisan cache:clear` if definitions stale
|
||||
- **Database Cleanup**: Run `SettingsCatalogDefinition::where('updated_at', '<', now()->subDays(30))->delete()` to prune old definitions
|
||||
- **Performance Monitoring**: Watch `policy_view` page load times in Telescope
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Implementation Status**: ✅ **CORE COMPLETE**
|
||||
|
||||
Phases 1-5 implemented successfully with:
|
||||
- ✅ Database schema + model
|
||||
- ✅ Definition resolver with 3-tier caching
|
||||
- ✅ Snapshot enrichment with cache warming
|
||||
- ✅ Normalizer with grouping/formatting
|
||||
- ✅ UI with accordion, search, and fallback
|
||||
|
||||
**Next Action**: **Phase 6 Manual Verification** (~20 min)
|
||||
|
||||
Navigate to Policy View and verify all features work as expected before proceeding to Phase 7 testing.
|
||||
|
||||
**Estimated Remaining Work**: ~7 hours
|
||||
- Phase 6: ~20 min
|
||||
- Phase 7: ~5-7 hours (tests + QA)
|
||||
|
||||
**Feature Delivery Target**: Ready for staging deployment after Phase 7 completion.
|
||||
312
specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md
Normal file
312
specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md
Normal file
@ -0,0 +1,312 @@
|
||||
# Feature 185: Manual Verification Guide (Phase 6)
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Estimated Time**: 20 minutes
|
||||
**Prerequisites**: Settings Catalog policy exists in database with snapshot
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
### Step 1: Navigate to Policy View (2 min)
|
||||
|
||||
1. Open browser: `http://localhost` (or your Sail URL)
|
||||
2. Login to Filament admin panel
|
||||
3. Navigate to **Policies** resource
|
||||
4. Click on a **Settings Catalog** policy (look for `settingsCatalogPolicy` type)
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ Page loads without errors
|
||||
- ✅ Policy details visible
|
||||
- ✅ No browser console errors
|
||||
|
||||
**If it fails**:
|
||||
- Check browser console for JavaScript errors
|
||||
- Run `./vendor/bin/sail artisan optimize:clear`
|
||||
- Verify policy has `versions` relationship loaded
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Verify Tabs Present (2 min)
|
||||
|
||||
**Action**: Look at the Policy View infolist
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ "Settings" tab visible
|
||||
- ✅ "JSON" tab visible
|
||||
- ✅ Settings tab is default (active)
|
||||
|
||||
**If tabs missing**:
|
||||
- Check if policy is actually Settings Catalog type
|
||||
- Verify PolicyResource.php has Tabs component for `policy_content`
|
||||
- Check Feature 002 JSON viewer implementation
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Verify Settings Tab - Accordion (5 min)
|
||||
|
||||
**Action**: Click on "Settings" tab (if not already active)
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ Accordion groups render
|
||||
- ✅ Each group has:
|
||||
- Title (e.g., "Device Vendor Msft", "Biometric Authentication")
|
||||
- Description (if available)
|
||||
- Setting count badge (e.g., "12 settings")
|
||||
- ✅ First group expanded by default
|
||||
- ✅ Other groups collapsed
|
||||
- ✅ Click group header toggles collapse/expand
|
||||
|
||||
**If accordion missing**:
|
||||
- Check if `metadata.definitions_cached === true` in snapshot
|
||||
- Verify normalizer returns groups structure
|
||||
- Check Blade component exists: `settings-catalog-grouped.blade.php`
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Verify Display Names (Not Definition IDs) (3 min)
|
||||
|
||||
**Action**: Expand a group and look at setting labels
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ Labels show human-readable names:
|
||||
- ✅ "Biometric Authentication" (NOT `device_vendor_msft_policy_biometric_authentication`)
|
||||
- ✅ "Password Minimum Length" (NOT `device_vendor_msft_policy_password_minlength`)
|
||||
- ✅ No `device_vendor_msft_...` visible in labels
|
||||
|
||||
**If definition IDs visible**:
|
||||
- Check if definitions cached in database: `SettingsCatalogDefinition::count()`
|
||||
- Run policy sync manually to trigger cache warming
|
||||
- Verify fallback message visible: "Definitions not yet cached..."
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Verify Value Formatting (5 min)
|
||||
|
||||
**Action**: Look at setting values in different groups
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ **Boolean values**: Badges with "Enabled" (green) or "Disabled" (gray)
|
||||
- ✅ **Integer values**: Formatted with separators (e.g., "1,000" not "1000")
|
||||
- ✅ **String values**: Truncated if >100 chars with "..."
|
||||
- ✅ **Choice values**: Show choice label (not raw ID)
|
||||
|
||||
**If formatting incorrect**:
|
||||
- Check `formatSettingsCatalogValue()` method in PolicyNormalizer
|
||||
- Verify Blade component conditionals for value types
|
||||
- Inspect browser to see actual rendered HTML
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Test Copy Buttons (2 min)
|
||||
|
||||
**Action**: Find a setting with a long value, click copy button
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ Copy button visible for long values
|
||||
- ✅ Click copy button → clipboard receives value
|
||||
- ✅ Button shows checkmark for 2 seconds
|
||||
- ✅ Button returns to copy icon after timeout
|
||||
|
||||
**If copy button missing/broken**:
|
||||
- Check Alpine.js loaded (inspect page source for `@livewireScripts`)
|
||||
- Verify clipboard API available (requires HTTPS or localhost)
|
||||
- Check browser console for JavaScript errors
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Test Search Filtering (Optional - if search visible) (2 min)
|
||||
|
||||
**Action**: Type in search box (if visible at top of Settings tab)
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ Search box visible with placeholder "Search settings..."
|
||||
- ✅ Type search query (e.g., "biometric")
|
||||
- ✅ Only matching settings shown
|
||||
- ✅ Non-matching groups hidden/empty
|
||||
- ✅ Clear search resets view
|
||||
|
||||
**If search not visible**:
|
||||
- This is expected for MVP (Blade-level implementation, no dedicated input yet)
|
||||
- Search logic exists in Blade template but may need Livewire wiring
|
||||
|
||||
---
|
||||
|
||||
### Step 8: Verify JSON Tab (2 min)
|
||||
|
||||
**Action**: Click "JSON" tab
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ Tab switches to JSON view
|
||||
- ✅ Snapshot renders with syntax highlighting
|
||||
- ✅ Copy button visible at top
|
||||
- ✅ Click copy button → full JSON copied to clipboard
|
||||
- ✅ Can switch back to Settings tab
|
||||
|
||||
**If JSON tab broken**:
|
||||
- Verify Feature 002 implementation still intact
|
||||
- Check `pepperfm/filament-json` package installed
|
||||
- Verify PolicyResource.php has JSON ViewEntry
|
||||
|
||||
---
|
||||
|
||||
### Step 9: Test Fallback Message (3 min)
|
||||
|
||||
**Action**: Find a Settings Catalog policy WITHOUT cached definitions (or manually delete definitions from database)
|
||||
|
||||
**Steps to test**:
|
||||
1. Run: `./vendor/bin/sail artisan tinker`
|
||||
2. Execute: `\App\Models\SettingsCatalogDefinition::truncate();`
|
||||
3. Navigate to Policy View for Settings Catalog policy
|
||||
4. Click Settings tab
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ Settings tab shows fallback message:
|
||||
- "Definitions not yet cached. Settings will be shown with raw IDs."
|
||||
- Helper text: "Switch to JSON tab or wait for next sync"
|
||||
- ✅ JSON tab still accessible
|
||||
- ✅ No error messages or broken layout
|
||||
|
||||
**If fallback not visible**:
|
||||
- Check conditional rendering in PolicyResource.php
|
||||
- Verify `metadata.definitions_cached` correctly set in snapshot
|
||||
- Check Blade component has fallback TextEntry
|
||||
|
||||
---
|
||||
|
||||
### Step 10: Test Dark Mode (Optional) (2 min)
|
||||
|
||||
**Action**: Toggle Filament dark mode (if available)
|
||||
|
||||
**Expected Result**:
|
||||
- ✅ Accordion groups adjust colors
|
||||
- ✅ Badges adjust colors (dark mode variants)
|
||||
- ✅ Text remains readable
|
||||
- ✅ No layout shifts or broken styles
|
||||
|
||||
**If dark mode broken**:
|
||||
- Check Blade component uses `dark:` Tailwind classes
|
||||
- Verify Filament Section components support dark mode
|
||||
- Inspect browser to see actual computed styles
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria Checklist
|
||||
|
||||
After completing all steps, mark these off:
|
||||
|
||||
- [ ] **T023**: JSON tab works (from Feature 002)
|
||||
- [ ] **T024**: Fallback message shows when definitions not cached
|
||||
- [ ] **T025**: JSON viewer only renders on Policy View (not globally)
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue: "Definitions not yet cached" persists
|
||||
|
||||
**Cause**: SyncPoliciesJob hasn't run yet or Graph API call failed
|
||||
|
||||
**Solution**:
|
||||
1. Manually trigger sync:
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
```
|
||||
```php
|
||||
$policy = \App\Models\Policy::first();
|
||||
\App\Jobs\SyncPoliciesJob::dispatch();
|
||||
```
|
||||
2. Check logs for Graph API errors:
|
||||
```bash
|
||||
./vendor/bin/sail artisan log:show
|
||||
```
|
||||
|
||||
### Issue: Accordion doesn't render
|
||||
|
||||
**Cause**: Blade component error or missing groups
|
||||
|
||||
**Solution**:
|
||||
1. Check browser console for errors
|
||||
2. Verify normalizer output:
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
```
|
||||
```php
|
||||
$policy = \App\Models\Policy::first();
|
||||
$snapshot = $policy->versions()->orderByDesc('captured_at')->value('snapshot');
|
||||
$normalizer = app(\App\Services\Intune\PolicyNormalizer::class);
|
||||
$groups = $normalizer->normalizeSettingsCatalogGrouped($snapshot['settings'] ?? []);
|
||||
dd($groups);
|
||||
```
|
||||
|
||||
### Issue: Copy buttons don't work
|
||||
|
||||
**Cause**: Alpine.js not loaded or clipboard API unavailable
|
||||
|
||||
**Solution**:
|
||||
1. Verify Alpine.js loaded:
|
||||
- Open browser console
|
||||
- Type `window.Alpine` → should return object
|
||||
2. Check HTTPS or localhost (clipboard API requires secure context)
|
||||
3. Fallback: Use "View JSON" tab and copy from there
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Verification
|
||||
|
||||
### If All Tests Pass ✅
|
||||
|
||||
Proceed to **Phase 7: Testing & Validation**
|
||||
|
||||
1. Write unit tests (T026-T031)
|
||||
2. Write feature tests (T032-T037)
|
||||
3. Run Pest suite (T038-T039)
|
||||
4. Manual QA walkthrough (T040-T042)
|
||||
|
||||
**Estimated Time**: ~5-7 hours
|
||||
|
||||
### If Issues Found ⚠️
|
||||
|
||||
1. Document issues in `specs/185-settings-catalog-readable/ISSUES.md`
|
||||
2. Fix critical issues (broken UI, errors)
|
||||
3. Re-run verification steps
|
||||
4. Proceed to Phase 7 only after verification passes
|
||||
|
||||
---
|
||||
|
||||
## Reporting Results
|
||||
|
||||
After completing verification, update tasks.md:
|
||||
|
||||
```bash
|
||||
# Mark T023-T025 as complete
|
||||
vim specs/185-settings-catalog-readable/tasks.md
|
||||
```
|
||||
|
||||
Add implementation notes:
|
||||
```markdown
|
||||
- [X] **T023** Verify JSON tab still works
|
||||
- **Implementation Note**: Verified tabs functional, JSON viewer renders snapshot
|
||||
|
||||
- [X] **T024** Add fallback for policies without cached definitions
|
||||
- **Implementation Note**: Fallback message shows info with guidance to JSON tab
|
||||
|
||||
- [X] **T025** Ensure JSON viewer only renders on Policy View
|
||||
- **Implementation Note**: Verified scoping correct, only shows on Policy resource
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contact & Support
|
||||
|
||||
If verification fails or you need assistance:
|
||||
|
||||
1. Check logs: `./vendor/bin/sail artisan log:show`
|
||||
2. Review implementation status: `specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md`
|
||||
3. Review code: `app/Services/Intune/`, `app/Filament/Resources/PolicyResource.php`
|
||||
4. Ask for help with specific error messages and context
|
||||
|
||||
---
|
||||
|
||||
**End of Manual Verification Guide**
|
||||
414
specs/185-settings-catalog-readable/plan.md
Normal file
414
specs/185-settings-catalog-readable/plan.md
Normal file
@ -0,0 +1,414 @@
|
||||
# Feature 185: Implementation Plan
|
||||
|
||||
## Tech Stack
|
||||
- **Backend**: Laravel 12, PHP 8.4
|
||||
- **Database**: PostgreSQL (JSONB for raw definition storage)
|
||||
- **Frontend**: Filament 4, Livewire 3, Tailwind CSS
|
||||
- **Graph Client**: Existing `GraphClientInterface`
|
||||
- **JSON Viewer**: `pepperfm/filament-json` (installed)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Services Layer
|
||||
```
|
||||
app/Services/Intune/
|
||||
├── SettingsCatalogDefinitionResolver.php (NEW)
|
||||
├── PolicyNormalizer.php (EXTEND)
|
||||
└── PolicySnapshotService.php (EXTEND)
|
||||
```
|
||||
|
||||
### Database Layer
|
||||
```
|
||||
database/migrations/
|
||||
└── xxxx_create_settings_catalog_definitions_table.php (NEW)
|
||||
|
||||
app/Models/
|
||||
└── SettingsCatalogDefinition.php (NEW)
|
||||
```
|
||||
|
||||
### UI Layer
|
||||
```
|
||||
app/Filament/Resources/
|
||||
├── PolicyResource.php (EXTEND - infolist with tabs)
|
||||
└── PolicyVersionResource.php (FUTURE - optional)
|
||||
|
||||
resources/views/filament/infolists/entries/
|
||||
└── settings-catalog-grouped.blade.php (NEW - accordion view)
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### 1. SettingsCatalogDefinitionResolver
|
||||
**Purpose**: Fetch and cache setting definitions from Graph API
|
||||
|
||||
**Key Methods**:
|
||||
- `resolve(array $definitionIds): array` - Batch resolve definitions
|
||||
- `resolveOne(string $definitionId): ?array` - Single definition lookup
|
||||
- `warmCache(array $definitionIds): void` - Pre-populate cache
|
||||
- `clearCache(?string $definitionId = null): void` - Cache invalidation
|
||||
|
||||
**Dependencies**:
|
||||
- `GraphClientInterface` - Graph API calls
|
||||
- `SettingsCatalogDefinition` model - Database cache
|
||||
- Laravel Cache - Memory-level cache
|
||||
|
||||
**Caching Strategy**:
|
||||
1. Check memory cache (request-level)
|
||||
2. Check database cache (30-day TTL)
|
||||
3. Fetch from Graph API
|
||||
4. Store in DB + memory
|
||||
|
||||
**Graph Endpoints**:
|
||||
- `/deviceManagement/configurationSettings` (global catalog)
|
||||
- `/deviceManagement/configurationPolicies/{id}/settings/{settingId}/settingDefinitions` (policy-specific)
|
||||
|
||||
### 2. PolicyNormalizer (Extension)
|
||||
**Purpose**: Transform Settings Catalog snapshot into UI-ready structure
|
||||
|
||||
**New Method**: `normalizeSettingsCatalog(array $snapshot, array $definitions): array`
|
||||
|
||||
**Output Structure**:
|
||||
```php
|
||||
[
|
||||
'type' => 'settings_catalog',
|
||||
'groups' => [
|
||||
[
|
||||
'title' => 'Windows Hello for Business',
|
||||
'description' => 'Configure biometric authentication settings',
|
||||
'settings' => [
|
||||
[
|
||||
'label' => 'Use biometrics',
|
||||
'value_display' => 'Enabled',
|
||||
'value_raw' => true,
|
||||
'help_text' => 'Allow users to sign in with fingerprint...',
|
||||
'definition_id' => 'device_vendor_msft_passportforwork_biometrics_usebiometrics',
|
||||
'instance_type' => 'ChoiceSettingInstance'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
```
|
||||
|
||||
**Value Formatting Rules**:
|
||||
- `ChoiceSettingInstance`: Extract choice label from `@odata.type` or value
|
||||
- `SimpleSetting` (bool): "Enabled" / "Disabled"
|
||||
- `SimpleSetting` (int): Number formatted with separators
|
||||
- `SimpleSetting` (string): Truncate >100 chars, add "..."
|
||||
- `GroupSettingCollectionInstance`: Flatten children recursively
|
||||
|
||||
**Grouping Strategy**:
|
||||
- Group by `categoryId` from definition metadata
|
||||
- Fallback: Group by first segment of definition ID (e.g., `device_vendor_msft_`)
|
||||
- Sort groups alphabetically
|
||||
|
||||
### 3. PolicySnapshotService (Extension)
|
||||
**Purpose**: Enrich snapshots with definition metadata after hydration
|
||||
|
||||
**Modified Flow**:
|
||||
```
|
||||
1. Hydrate settings from Graph (existing)
|
||||
2. Extract all settingDefinitionId + children (NEW)
|
||||
3. Call SettingsCatalogDefinitionResolver::warmCache() (NEW)
|
||||
4. Add metadata to snapshot: definitions_cached, definition_count (NEW)
|
||||
5. Save snapshot (existing)
|
||||
```
|
||||
|
||||
**Non-Blocking**: Definition resolution should not block policy sync
|
||||
- Use try/catch for Graph API calls
|
||||
- Mark `definitions_cached: false` on failure
|
||||
- Continue with snapshot save
|
||||
|
||||
### 4. PolicyResource (UI Extension)
|
||||
**Purpose**: Render Settings Catalog policies with readable UI
|
||||
|
||||
**Changes**:
|
||||
1. Add Tabs component to infolist:
|
||||
- "Settings" tab (default)
|
||||
- "JSON" tab (existing Feature 002 implementation)
|
||||
|
||||
2. Settings Tab Structure:
|
||||
- Search/filter input (top)
|
||||
- Accordion component (groups)
|
||||
- Each group: Section with settings table
|
||||
- Fallback: Show info message if no definitions cached
|
||||
|
||||
3. JSON Tab:
|
||||
- Existing implementation from Feature 002
|
||||
- Shows full snapshot with copy button
|
||||
|
||||
**Conditional Rendering**:
|
||||
- Show tabs ONLY for `settingsCatalogPolicy` type
|
||||
- For other policy types: Keep existing simple sections
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Table: `settings_catalog_definitions`
|
||||
```sql
|
||||
CREATE TABLE settings_catalog_definitions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
definition_id VARCHAR(500) UNIQUE NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
help_text TEXT,
|
||||
category_id VARCHAR(255),
|
||||
ux_behavior VARCHAR(100),
|
||||
raw JSONB NOT NULL,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_definition_id ON settings_catalog_definitions(definition_id);
|
||||
CREATE INDEX idx_category_id ON settings_catalog_definitions(category_id);
|
||||
CREATE INDEX idx_raw_gin ON settings_catalog_definitions USING GIN(raw);
|
||||
```
|
||||
|
||||
**Indexes**:
|
||||
- `definition_id` - Primary lookup key
|
||||
- `category_id` - Grouping queries
|
||||
- `raw` (GIN) - JSONB queries if needed
|
||||
|
||||
## Graph API Integration
|
||||
|
||||
### Endpoints Used
|
||||
|
||||
1. **Global Catalog** (Preferred):
|
||||
```
|
||||
GET /deviceManagement/configurationSettings
|
||||
GET /deviceManagement/configurationSettings/{settingDefinitionId}
|
||||
```
|
||||
|
||||
2. **Policy-Specific** (Fallback):
|
||||
```
|
||||
GET /deviceManagement/configurationPolicies/{policyId}/settings/{settingId}/settingDefinitions
|
||||
```
|
||||
|
||||
### Request Optimization
|
||||
- Batch requests where possible
|
||||
- Use `$select` to limit fields
|
||||
- Use `$filter` for targeted lookups
|
||||
- Respect rate limits (429 retry logic)
|
||||
|
||||
## UI/UX Flow
|
||||
|
||||
### Policy View Page Flow
|
||||
1. User navigates to `/admin/policies/{id}`
|
||||
2. Policy details loaded (existing)
|
||||
3. Check policy type:
|
||||
- If `settingsCatalogPolicy`: Show tabs
|
||||
- Else: Show existing sections
|
||||
4. Default to "Settings" tab
|
||||
5. Load normalized settings from PolicyNormalizer
|
||||
6. Render accordion with groups
|
||||
7. User can search/filter settings
|
||||
8. User can switch to "JSON" tab for raw view
|
||||
|
||||
### Settings Tab Layout
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [Search settings...] [🔍] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ▼ Windows Hello for Business │
|
||||
│ ├─ Use biometrics: Enabled │
|
||||
│ ├─ Use facial recognition: Disabled │
|
||||
│ └─ PIN minimum length: 6 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ▼ Device Lock Settings │
|
||||
│ ├─ Password expiration days: 90 │
|
||||
│ └─ Password history: 5 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### JSON Tab Layout
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Full Policy Configuration [Copy] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ { │
|
||||
│ "@odata.type": "...", │
|
||||
│ "name": "WHFB Settings", │
|
||||
│ "settings": [...] │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Definition Not Found
|
||||
- **UI**: Show prettified definition ID
|
||||
- **Label**: Convert `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name"
|
||||
- **Icon**: Info icon with tooltip "Definition not cached"
|
||||
|
||||
### Graph API Failure
|
||||
- **During Sync**: Mark `definitions_cached: false`, continue
|
||||
- **During View**: Show cached data or fallback labels
|
||||
- **Log**: Record Graph API errors for debugging
|
||||
|
||||
### Malformed Snapshot
|
||||
- **Validation**: Check for required fields before normalization
|
||||
- **Fallback**: Show raw JSON tab, hide Settings tab
|
||||
- **Warning**: Display admin-friendly error message
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Database Queries
|
||||
- Eager load definitions for all settings in one query
|
||||
- Use `whereIn()` for batch lookups
|
||||
- Index on `definition_id` ensures fast lookups
|
||||
|
||||
### Memory Management
|
||||
- Request-level cache using Laravel Cache
|
||||
- Limit batch size to 100 definitions per request
|
||||
- Clear memory cache after request
|
||||
|
||||
### UI Rendering
|
||||
- Accordion lazy-loads groups (only render expanded)
|
||||
- Pagination for policies with >50 groups
|
||||
- Virtualized list for very large policies (future)
|
||||
|
||||
### Caching TTL
|
||||
- Database: 30 days (definitions change rarely)
|
||||
- Memory: Request duration only
|
||||
- Background refresh: Optional scheduled job
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Graph API Permissions
|
||||
- Existing `DeviceManagementConfiguration.Read.All` sufficient
|
||||
- No new permissions required
|
||||
|
||||
### Data Sanitization
|
||||
- Escape HTML in display names and descriptions
|
||||
- Validate definition ID format before lookups
|
||||
- Prevent XSS in value rendering
|
||||
|
||||
### Audit Logging
|
||||
- Log definition cache misses
|
||||
- Log Graph API failures
|
||||
- Track definition cache updates
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
**File**: `tests/Unit/SettingsCatalogDefinitionResolverTest.php`
|
||||
- Test batch resolution
|
||||
- Test caching behavior (memory + DB)
|
||||
- Test fallback when definition not found
|
||||
- Test Graph API error handling
|
||||
|
||||
**File**: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php`
|
||||
- Test grouping logic
|
||||
- Test value formatting (bool, int, choice, string)
|
||||
- Test fallback labels
|
||||
- Test nested group flattening
|
||||
|
||||
### Feature Tests
|
||||
**File**: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php`
|
||||
- Mock Graph API responses
|
||||
- Assert tabs present for Settings Catalog policies
|
||||
- Assert display names shown (not definition IDs)
|
||||
- Assert values formatted correctly
|
||||
- Assert search/filter works
|
||||
- Assert JSON tab accessible
|
||||
- Assert graceful degradation for missing definitions
|
||||
|
||||
### Manual QA Checklist
|
||||
1. Open Policy View for Settings Catalog policy
|
||||
2. Verify tabs present: "Settings" and "JSON"
|
||||
3. Verify Settings tab shows groups with accordion
|
||||
4. Verify display names shown (not raw IDs)
|
||||
5. Verify values formatted (True/False, numbers, etc.)
|
||||
6. Test search: Type setting name, verify filtering
|
||||
7. Switch to JSON tab, verify snapshot shown
|
||||
8. Test copy button in JSON tab
|
||||
9. Test dark mode toggle
|
||||
10. Test with policy missing definitions (fallback labels)
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Database Migration
|
||||
```bash
|
||||
./vendor/bin/sail artisan migrate
|
||||
```
|
||||
|
||||
### 2. Cache Warming (Optional)
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> $resolver = app(\App\Services\Intune\SettingsCatalogDefinitionResolver::class);
|
||||
>>> $resolver->warmCache([...definitionIds...]);
|
||||
```
|
||||
|
||||
### 3. Clear Caches
|
||||
```bash
|
||||
./vendor/bin/sail artisan optimize:clear
|
||||
```
|
||||
|
||||
### 4. Verify
|
||||
- Navigate to Policy View
|
||||
- Check browser console for errors
|
||||
- Check Laravel logs for Graph API errors
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
### If Critical Issues Found
|
||||
1. Revert database migration:
|
||||
```bash
|
||||
./vendor/bin/sail artisan migrate:rollback
|
||||
```
|
||||
|
||||
2. Revert code changes (Git):
|
||||
```bash
|
||||
git revert <commit-hash>
|
||||
```
|
||||
|
||||
3. Clear caches:
|
||||
```bash
|
||||
./vendor/bin/sail artisan optimize:clear
|
||||
```
|
||||
|
||||
### Partial Rollback
|
||||
- Remove tabs, keep existing table view
|
||||
- Disable definition resolver, show raw IDs
|
||||
- Keep database table for future use
|
||||
|
||||
## Dependencies on Feature 002
|
||||
|
||||
**Shared**:
|
||||
- `pepperfm/filament-json` package (installed)
|
||||
- JSON viewer CSS assets (published)
|
||||
- Tab component pattern (Filament Schemas)
|
||||
|
||||
**Independent**:
|
||||
- Feature 185 can work without Feature 002 completed
|
||||
- Feature 002 provided JSON tab foundation
|
||||
- Feature 185 adds Settings tab with readable UI
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Phase 1** (Foundation): 2-3 hours
|
||||
- **Phase 2** (Snapshot): 1 hour
|
||||
- **Phase 3** (Normalizer): 2-3 hours
|
||||
- **Phase 4** (UI): 3-4 hours
|
||||
- **Phase 5** (Testing): 2-3 hours
|
||||
- **Total**: ~11-15 hours
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **User Experience**:
|
||||
- Admins can read policy settings without raw JSON
|
||||
- Search finds settings in <200ms
|
||||
- Accordion groups reduce scrolling
|
||||
|
||||
2. **Performance**:
|
||||
- Definition resolution: <500ms for 50 definitions
|
||||
- UI render: <2s for 200 settings
|
||||
- Search response: <200ms
|
||||
|
||||
3. **Quality**:
|
||||
- 100% test coverage for resolver
|
||||
- Zero broken layouts for missing definitions
|
||||
- Zero Graph API errors logged (with proper retry)
|
||||
|
||||
4. **Adoption**:
|
||||
- Settings tab used >80% of time vs JSON tab
|
||||
- Zero support tickets about "unreadable settings"
|
||||
240
specs/185-settings-catalog-readable/spec.md
Normal file
240
specs/185-settings-catalog-readable/spec.md
Normal file
@ -0,0 +1,240 @@
|
||||
# Feature 185: Intune-like "Cleartext Settings" on Policy View
|
||||
|
||||
## Overview
|
||||
Display Settings Catalog policies in Policy View with human-readable setting names, descriptions, and formatted values—similar to Intune Portal experience—instead of raw JSON and definition IDs.
|
||||
|
||||
## Problem Statement
|
||||
Admins cannot effectively work with Settings Catalog policies when they only see:
|
||||
- `settingDefinitionId` strings (e.g., `device_vendor_msft_passportforwork_biometrics_usebiometrics`)
|
||||
- Raw JSON structures
|
||||
- Choice values as GUIDs or internal strings
|
||||
|
||||
This makes policy review, audit, and troubleshooting extremely difficult.
|
||||
|
||||
## Goals
|
||||
- **Primary**: Render Settings Catalog policies with display names, descriptions, grouped settings, and formatted values
|
||||
- **Secondary**: Keep raw JSON available for audit/restore workflows
|
||||
- **Tertiary**: Gracefully degrade when definition metadata is unavailable
|
||||
|
||||
## User Stories
|
||||
|
||||
### P1: US-UI-04 - Admin Views Readable Settings
|
||||
**As an** Intune admin
|
||||
**I want to** see policy settings with human-readable names and descriptions
|
||||
**So that** I can understand what the policy configures without reading raw JSON
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Display name shown for each setting (not definition ID)
|
||||
- Description/help text visible on hover or expand
|
||||
- Values formatted appropriately (True/False, numbers, choice labels)
|
||||
- Settings grouped by category/section
|
||||
|
||||
### P2: US-UI-05 - Admin Searches/Filters Settings
|
||||
**As an** Intune admin
|
||||
**I want to** search and filter settings by name or value
|
||||
**So that** I can quickly find specific configurations in large policies
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Search box filters settings list
|
||||
- Search works on display name and value
|
||||
- Results update instantly
|
||||
- Clear search resets view
|
||||
|
||||
### P3: US-UI-06 - Admin Accesses Raw JSON When Needed
|
||||
**As an** Intune admin or auditor
|
||||
**I want to** switch to raw JSON view
|
||||
**So that** I can see the exact Graph API payload for audit/restore
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Tab navigation between "Settings" and "JSON" views
|
||||
- JSON view shows complete policy snapshot
|
||||
- JSON view includes copy-to-clipboard
|
||||
- Settings view is default
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-185.1: Setting Definition Resolver Service
|
||||
- **Input**: Array of `settingDefinitionId` (including children from group settings)
|
||||
- **Output**: Map of `{definitionId => {displayName, description, helpText, categoryId, uxBehavior, ...}}`
|
||||
- **Strategy**:
|
||||
- Fetch from Graph API settingDefinitions endpoints
|
||||
- Cache in database (`settings_catalog_definitions` table)
|
||||
- Memory cache for request-level performance
|
||||
- Fallback to prettified ID if definition not found
|
||||
|
||||
### FR-185.2: Database Schema for Definition Cache
|
||||
**Table**: `settings_catalog_definitions`
|
||||
- `id` (bigint, PK)
|
||||
- `definition_id` (string, unique, indexed)
|
||||
- `display_name` (string)
|
||||
- `description` (text, nullable)
|
||||
- `help_text` (text, nullable)
|
||||
- `category_id` (string, nullable)
|
||||
- `ux_behavior` (string, nullable)
|
||||
- `raw` (jsonb) - full Graph response
|
||||
- `timestamps`
|
||||
|
||||
### FR-185.3: Snapshot Enrichment (Non-Blocking)
|
||||
- After hydrating `/configurationPolicies/{id}/settings`
|
||||
- Extract all `settingDefinitionId` + children
|
||||
- Call resolver to warm cache
|
||||
- Store render hints in snapshot metadata: `definitions_cached: true/false`, `definition_count: N`
|
||||
|
||||
### FR-185.4: PolicyNormalizer Enhancement
|
||||
- For `settingsCatalogPolicy` type:
|
||||
- Output: `settings_groups[]` = `{title, description?, rows[]}`
|
||||
- Each row: `{label, helpText?, value_display, value_raw, definition_id, instance_type}`
|
||||
- Value formatting:
|
||||
- `integer/bool`: show compact (True/False, numbers)
|
||||
- `choice`: show friendly choice label (extract from `@odata.type` or value tail)
|
||||
- `string`: truncate long values, add copy button
|
||||
- Fallback: prettify `definitionId` if definition not found (e.g., `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name")
|
||||
|
||||
### FR-185.5: Policy View UI Update
|
||||
- **Layout**: 2-column
|
||||
- Left: "Configuration Settings" (grouped, searchable)
|
||||
- Right: "Policy Details" (existing metadata: name, type, platform, last synced)
|
||||
- **Tabs**:
|
||||
- "Settings" (default) - cleartext UI with accordion groups
|
||||
- "JSON" - raw snapshot viewer (pepperfm/filament-json)
|
||||
- **Search/Filter**: Live search on setting display name and value
|
||||
- **Accordion**: Settings grouped by category, collapsible
|
||||
- **Fallback**: Generic table for non-Settings Catalog policies (existing behavior)
|
||||
|
||||
### FR-185.6: JSON Viewer Integration
|
||||
- Use `pepperfm/filament-json` only on Policy View and Policy Version View
|
||||
- Not rendered globally
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
### NFR-185.1: Performance
|
||||
- Definition resolver: <500ms for batch of 50 definitions (cached)
|
||||
- UI render: <2s for policy with 200 settings
|
||||
- Search/filter: <200ms response time
|
||||
|
||||
### NFR-185.2: Caching Strategy
|
||||
- DB cache: 30 days TTL for definitions
|
||||
- Memory cache: Request-level only
|
||||
- Cache warming: Background job after policy sync (optional)
|
||||
|
||||
### NFR-185.3: Graceful Degradation
|
||||
- If definition not found: show prettified ID
|
||||
- If Graph API fails: show cached data or fallback
|
||||
- If no cache: show raw definition ID with info icon
|
||||
|
||||
### NFR-185.4: Maintainability
|
||||
- Resolver service isolated, testable
|
||||
- Normalizer logic separated from UI
|
||||
- UI components reusable for Version view
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Services
|
||||
1. **SettingsCatalogDefinitionResolver** (`app/Services/Intune/`)
|
||||
- `resolve(array $definitionIds): array`
|
||||
- `resolveOne(string $definitionId): ?array`
|
||||
- `warmCache(array $definitionIds): void`
|
||||
- Uses GraphClientInterface
|
||||
- Database: `SettingsCatalogDefinition` model
|
||||
|
||||
2. **PolicyNormalizer** (extend existing)
|
||||
- `normalizeSettingsCatalog(array $snapshot, array $definitions): array`
|
||||
- Returns structured groups + rows
|
||||
|
||||
### Database
|
||||
**Migration**: `create_settings_catalog_definitions_table`
|
||||
**Model**: `SettingsCatalogDefinition` (Eloquent)
|
||||
|
||||
### UI Components
|
||||
**Resource**: `PolicyResource` (extend infolist)
|
||||
- Tabs component
|
||||
- Accordion for groups
|
||||
- Search/filter component
|
||||
- ViewEntry for settings table
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation (Resolver + DB)
|
||||
1. Create migration `settings_catalog_definitions`
|
||||
2. Create model `SettingsCatalogDefinition`
|
||||
3. Create service `SettingsCatalogDefinitionResolver`
|
||||
4. Add Graph client method for fetching definitions
|
||||
5. Implement cache logic (DB + memory)
|
||||
|
||||
### Phase 2: Snapshot Enrichment
|
||||
1. Extend `PolicySnapshotService` to extract definition IDs
|
||||
2. Call resolver after settings hydration
|
||||
3. Store metadata in snapshot
|
||||
|
||||
### Phase 3: Normalizer Enhancement
|
||||
1. Extend `PolicyNormalizer` for Settings Catalog
|
||||
2. Implement value formatting logic
|
||||
3. Implement grouping logic
|
||||
4. Add fallback for missing definitions
|
||||
|
||||
### Phase 4: UI Implementation
|
||||
1. Update `PolicyResource` infolist with tabs
|
||||
2. Create accordion view for settings groups
|
||||
3. Add search/filter functionality
|
||||
4. Integrate JSON viewer (pepperfm)
|
||||
5. Add fallback for non-Settings Catalog policies
|
||||
|
||||
### Phase 5: Testing & Polish
|
||||
1. Unit tests for resolver
|
||||
2. Feature tests for UI
|
||||
3. Manual QA on staging
|
||||
4. Performance profiling
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- `SettingsCatalogDefinitionResolverTest`
|
||||
- Test definition mapping
|
||||
- Test caching behavior
|
||||
- Test fallback logic
|
||||
- Test batch resolution
|
||||
|
||||
### Feature Tests
|
||||
- `PolicyViewSettingsCatalogReadableTest`
|
||||
- Mock Graph responses
|
||||
- Assert UI shows display names
|
||||
- Assert values formatted correctly
|
||||
- Assert grouping works
|
||||
- Assert search/filter works
|
||||
- Assert JSON tab available
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Admin sees human-readable setting names + descriptions
|
||||
2. ✅ Values formatted appropriately (True/False, numbers, choice labels)
|
||||
3. ✅ Settings grouped by category with accordion
|
||||
4. ✅ Search/filter works on display name and value
|
||||
5. ✅ Raw JSON available in separate tab
|
||||
6. ✅ Unknown settings show prettified ID (no broken layout)
|
||||
7. ✅ Performance: <2s render for 200 settings
|
||||
8. ✅ Tests pass: Unit + Feature
|
||||
|
||||
## Dependencies
|
||||
- Existing: `PolicyNormalizer`, `PolicySnapshotService`, `GraphClientInterface`
|
||||
- New: `pepperfm/filament-json` (already installed in Feature 002)
|
||||
- Database: PostgreSQL with JSONB support
|
||||
|
||||
## Risks & Mitigations
|
||||
- **Risk**: Graph API rate limiting when fetching definitions
|
||||
- **Mitigation**: Aggressive caching, batch requests, background warming
|
||||
- **Risk**: Definition schema changes by Microsoft
|
||||
- **Mitigation**: Raw JSONB storage allows flexible parsing, version metadata
|
||||
- **Risk**: Large policies (1000+ settings) slow UI
|
||||
- **Mitigation**: Pagination, lazy loading accordion groups, virtualized lists
|
||||
|
||||
## Out of Scope
|
||||
- Editing settings (read-only view only)
|
||||
- Definition schema versioning
|
||||
- Multi-language support for definitions
|
||||
- Real-time definition updates (cache refresh manual/scheduled)
|
||||
|
||||
## Future Enhancements
|
||||
- Background job to pre-warm definition cache
|
||||
- Definition schema versioning
|
||||
- Comparison view between policy versions (diff)
|
||||
- Export settings to CSV/Excel
|
||||
472
specs/185-settings-catalog-readable/tasks.md
Normal file
472
specs/185-settings-catalog-readable/tasks.md
Normal file
@ -0,0 +1,472 @@
|
||||
# Feature 185: Settings Catalog Readable UI - Tasks
|
||||
|
||||
## Summary
|
||||
- **Total Tasks**: 42
|
||||
- **User Stories**: 3 (US-UI-04, US-UI-05, US-UI-06)
|
||||
- **Estimated Time**: 11-15 hours
|
||||
- **Phases**: 7
|
||||
|
||||
## FR→Task Traceability
|
||||
|
||||
| FR | Description | Tasks |
|
||||
|----|-------------|-------|
|
||||
| FR-185.1 | Setting Definition Resolver Service | T003, T004, T005, T006, T007 |
|
||||
| FR-185.2 | Database Schema | T001, T002 |
|
||||
| FR-185.3 | Snapshot Enrichment | T008, T009, T010 |
|
||||
| FR-185.4 | PolicyNormalizer Enhancement | T011, T012, T013, T014 |
|
||||
| FR-185.5 | Policy View UI Update | T015-T024 |
|
||||
| FR-185.6 | JSON Viewer Integration | T025 |
|
||||
|
||||
## User Story→Task Mapping
|
||||
|
||||
| User Story | Tasks | Success Criteria |
|
||||
|------------|-------|------------------|
|
||||
| US-UI-04 (Readable Settings) | T015-T020 | Display names shown, values formatted, grouped by category |
|
||||
| US-UI-05 (Search/Filter) | T021, T022 | Search box works, filters settings, instant results |
|
||||
| US-UI-06 (Raw JSON Access) | T023, T024, T025 | Tabs present, JSON view works, copy button functional |
|
||||
|
||||
## Measurable Thresholds
|
||||
- **Definition Resolution**: <500ms for batch of 50 definitions (cached)
|
||||
- **UI Render**: <2s for policy with 200 settings
|
||||
- **Search Response**: <200ms filter update
|
||||
- **Database Cache TTL**: 30 days
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Database Foundation (T001-T002)
|
||||
|
||||
**Goal**: Create database schema for caching setting definitions
|
||||
|
||||
- [X] **T001** Create migration for `settings_catalog_definitions` table
|
||||
- Schema: id, definition_id (unique), display_name, description, help_text, category_id, ux_behavior, raw (jsonb), timestamps
|
||||
- Indexes: definition_id (unique), category_id, raw (GIN)
|
||||
- File: `database/migrations/2025_12_13_212126_create_settings_catalog_definitions_table.php`
|
||||
- **Implementation Note**: Created migration with GIN index for JSONB, ran successfully
|
||||
|
||||
- [X] **T002** Create `SettingsCatalogDefinition` Eloquent model
|
||||
- Casts: raw → array
|
||||
- Fillable: definition_id, display_name, description, help_text, category_id, ux_behavior, raw
|
||||
- File: `app/Models/SettingsCatalogDefinition.php`
|
||||
- **Implementation Note**: Added helper methods findByDefinitionId() and findByDefinitionIds() for efficient lookups
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Definition Resolver Service (T003-T007)
|
||||
|
||||
**Goal**: Implement service to fetch and cache setting definitions from Graph API
|
||||
|
||||
**User Story**: US-UI-04 (foundation)
|
||||
|
||||
- [X] **T003** [P] Create `SettingsCatalogDefinitionResolver` service skeleton
|
||||
- Constructor: inject GraphClientInterface, SettingsCatalogDefinition model
|
||||
- Methods: resolve(), resolveOne(), warmCache(), clearCache()
|
||||
- File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php`
|
||||
- **Implementation Note**: Complete service with 3-tier caching (memory → DB → Graph API)
|
||||
|
||||
- [X] **T004** [P] [US1] Implement `resolve(array $definitionIds): array` method
|
||||
- Check memory cache (Laravel Cache)
|
||||
- Check database cache
|
||||
- Batch fetch missing from Graph API: `/deviceManagement/configurationSettings?$filter=id in (...)`
|
||||
- Store in DB + memory cache
|
||||
- Return map: `{definitionId => {displayName, description, ...}}`
|
||||
- File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php`
|
||||
- **Implementation Note**: Implemented with batch request optimization and error handling
|
||||
|
||||
- [X] **T005** [P] [US1] Implement `resolveOne(string $definitionId): ?array` method
|
||||
- Single definition lookup
|
||||
- Same caching strategy as resolve()
|
||||
- Return null if not found
|
||||
- File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php`
|
||||
- **Implementation Note**: Wraps resolve() for single ID lookup
|
||||
|
||||
- [X] **T006** [US1] Implement fallback logic for missing definitions
|
||||
- Prettify definition ID: `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name"
|
||||
- Return fallback structure: `{displayName: prettified, description: null, ...}`
|
||||
- File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php`
|
||||
- **Implementation Note**: prettifyDefinitionId() method with Str::title() conversion, isFallback flag added
|
||||
|
||||
- [X] **T007** [P] Implement `warmCache(array $definitionIds): void` method
|
||||
- Pre-populate cache without returning data
|
||||
- Non-blocking: catch and log Graph API errors
|
||||
- File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php`
|
||||
- **Implementation Note**: Non-blocking implementation with try/catch, logs warnings on failure
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Snapshot Enrichment (T008-T010)
|
||||
|
||||
**Goal**: Extend PolicySnapshotService to warm definition cache after settings hydration
|
||||
|
||||
**User Story**: US-UI-04 (foundation)
|
||||
|
||||
- [X] **T008** [US1] Extend `PolicySnapshotService` to extract definition IDs
|
||||
- After hydrating `/configurationPolicies/{id}/settings`
|
||||
- Extract all `settingDefinitionId` from settings array
|
||||
- Include children from `groupSettingCollectionInstance`
|
||||
- File: `app/Services/Intune/PolicySnapshotService.php`
|
||||
- **Implementation Note**: Added extractDefinitionIds() method with recursive extraction from nested children
|
||||
|
||||
- [X] **T009** [US1] Call SettingsCatalogDefinitionResolver::warmCache() in snapshot flow
|
||||
- Pass extracted definition IDs to resolver
|
||||
- Non-blocking: use try/catch for Graph API calls
|
||||
- File: `app/Services/Intune/PolicySnapshotService.php`
|
||||
- **Implementation Note**: Integrated warmCache() call in hydrateSettingsCatalog() after settings extraction
|
||||
|
||||
- [X] **T010** [US1] Add metadata to snapshot about definition cache status
|
||||
- Add to snapshot: `definitions_cached: true/false`, `definition_count: N`
|
||||
- Store with snapshot data
|
||||
- File: `app/Services/Intune/PolicySnapshotService.php`
|
||||
- **Implementation Note**: Added definitions_cached and definition_count to metadata
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: PolicyNormalizer Enhancement (T011-T014)
|
||||
|
||||
**Goal**: Transform Settings Catalog snapshots into UI-ready grouped structure
|
||||
|
||||
**User Story**: US-UI-04
|
||||
|
||||
- [X] **T011** [US1] Create `normalizeSettingsCatalogGrouped()` method in PolicyNormalizer
|
||||
- Input: array $snapshot, array $definitions
|
||||
- Output: array with groups[] structure
|
||||
- Extract settings from snapshot
|
||||
- Resolve definitions for all setting IDs
|
||||
- File: `app/Services/Intune/PolicyNormalizer.php`
|
||||
- **Implementation Note**: Complete method with definition resolution integration
|
||||
|
||||
- [X] **T012** [US1] Implement value formatting logic
|
||||
- ChoiceSettingInstance: Extract choice label from @odata.type or value
|
||||
- SimpleSetting (bool): "Enabled" / "Disabled"
|
||||
- SimpleSetting (int): Number formatted with separators
|
||||
- SimpleSetting (string): Truncate >100 chars, add "..."
|
||||
- File: `app/Services/Intune/PolicyNormalizer.php`
|
||||
- **Implementation Note**: Added formatSettingsCatalogValue() method with all formatting rules
|
||||
|
||||
- [X] **T013** [US1] Implement grouping logic by category
|
||||
- Group settings by categoryId from definition metadata
|
||||
- Fallback: Group by first segment of definition ID
|
||||
- Sort groups alphabetically by title
|
||||
- File: `app/Services/Intune/PolicyNormalizer.php`
|
||||
- **Implementation Note**: Added groupSettingsByCategory() with fallback extraction from definition IDs
|
||||
|
||||
- [X] **T014** [US1] Implement nested group flattening for groupSettingCollectionInstance
|
||||
- Recursively extract children from group settings
|
||||
- Maintain hierarchy in output structure
|
||||
- Include parent context in child labels
|
||||
- File: `app/Services/Intune/PolicyNormalizer.php`
|
||||
- **Implementation Note**: Recursive walk function handles nested children and group collections
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: UI Implementation - Settings Tab (T015-T022)
|
||||
|
||||
**Goal**: Create readable Settings Catalog UI with accordion, search, and formatting
|
||||
|
||||
**User Stories**: US-UI-04, US-UI-05
|
||||
|
||||
- [X] **T015** [US1] Add Tabs component to PolicyResource infolist for settingsCatalogPolicy
|
||||
- Conditional rendering: only for settingsCatalogPolicy type
|
||||
- Tab 1: "Settings" (default)
|
||||
- Tab 2: "JSON" (existing from Feature 002)
|
||||
- File: `app/Filament/Resources/PolicyResource.php`
|
||||
- **Implementation Note**: Tabs already exist from Feature 002, extended Settings tab with grouped view
|
||||
|
||||
- [X] **T016** [US1] Create Settings tab schema with search input
|
||||
- TextInput for search/filter at top
|
||||
- Placeholder: "Search settings..."
|
||||
- Wire with Livewire for live filtering
|
||||
- File: `app/Filament/Resources/PolicyResource.php`
|
||||
- **Implementation Note**: Added search_info TextEntry (hidden for MVP), search implemented in Blade template
|
||||
|
||||
- [X] **T017** [US1] Create Blade component for grouped settings accordion
|
||||
- File: `resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php`
|
||||
- Props: groups (from normalizer), searchQuery
|
||||
- Render accordion with Filament Section components
|
||||
- **Implementation Note**: Complete Blade component with Filament Section integration
|
||||
|
||||
- [X] **T018** [US1] Implement accordion group rendering
|
||||
- Each group: Section with title + description
|
||||
- Collapsible by default (first group expanded)
|
||||
- Group header shows setting count
|
||||
- File: `settings-catalog-grouped.blade.php`
|
||||
- **Implementation Note**: Using x-filament::section with collapsible, first group expanded by default
|
||||
|
||||
- [X] **T019** [US1] Implement setting row rendering within groups
|
||||
- Layout: Label (bold) | Value (formatted) | Help icon
|
||||
- Help icon: Tooltip with description + helpText
|
||||
- Copy button for long values
|
||||
- File: `settings-catalog-grouped.blade.php`
|
||||
- **Implementation Note**: Flexbox layout with label, help text, value display, and Alpine.js copy button
|
||||
|
||||
- [X] **T020** [US1] Add value formatting in Blade template
|
||||
- Bool: Badge (Enabled/Disabled with colors)
|
||||
- Int: Formatted number
|
||||
- String: Truncate with "..." and expand button
|
||||
- Choice: Show choice label
|
||||
- File: `settings-catalog-grouped.blade.php`
|
||||
- **Implementation Note**: Conditional rendering based on value type, badges for bool, monospace for int
|
||||
|
||||
- [X] **T021** [US2] Implement search/filter logic in Livewire component
|
||||
- Filter groups and settings by search query
|
||||
- Search on display_name and value_display
|
||||
- Update accordion to show only matching settings
|
||||
- File: `app/Filament/Resources/PolicyResource.php` (or custom Livewire component)
|
||||
- **Implementation Note**: Blade-level filtering using searchQuery prop, no Livewire component needed for MVP
|
||||
|
||||
- [X] **T022** [US2] Add "No results" empty state for search
|
||||
- Show message when search returns no matches
|
||||
- Provide "Clear search" button
|
||||
- File: `settings-catalog-grouped.blade.php`
|
||||
- **Implementation Note**: Empty state with clear search button using wire:click
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: UI Implementation - Tabs & Fallback (T023-T025)
|
||||
|
||||
**Goal**: Complete tab navigation and handle non-Settings Catalog policies
|
||||
|
||||
**User Story**: US-UI-06
|
||||
|
||||
- [ ] **T023** [US3] Verify JSON tab still works (from Feature 002)
|
||||
- Tab navigation switches correctly
|
||||
- JSON viewer renders snapshot
|
||||
- Copy button functional
|
||||
- File: `app/Filament/Resources/PolicyResource.php`
|
||||
|
||||
- [ ] **T024** [US3] Add fallback for policies without cached definitions
|
||||
- Show info message in Settings tab: "Definitions not cached. Showing raw data."
|
||||
- Display raw definition IDs with prettified labels
|
||||
- Link to "View JSON" tab
|
||||
- File: `settings-catalog-grouped.blade.php`
|
||||
|
||||
- [ ] **T025** Ensure JSON viewer only renders on Policy View (not globally)
|
||||
- Check existing implementation from Feature 002
|
||||
- Verify pepperfm/filament-json scoped correctly
|
||||
- File: `app/Filament/Resources/PolicyResource.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Testing & Validation (T026-T042)
|
||||
|
||||
**Goal**: Comprehensive testing for resolver, normalizer, and UI
|
||||
|
||||
### Unit Tests (T026-T031)
|
||||
|
||||
- [ ] **T026** [P] Create `SettingsCatalogDefinitionResolverTest` test file
|
||||
- File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php`
|
||||
- Setup: Mock GraphClientInterface, in-memory database
|
||||
|
||||
- [ ] **T027** [P] Test `resolve()` method with batch of definition IDs
|
||||
- Assert: Returns map with display names
|
||||
- Assert: Caches in database
|
||||
- Assert: Uses cached data on second call
|
||||
- File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php`
|
||||
|
||||
- [ ] **T028** [P] Test fallback logic for missing definitions
|
||||
- Mock: Graph API returns 404
|
||||
- Assert: Returns prettified definition ID
|
||||
- Assert: No exception thrown
|
||||
- File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php`
|
||||
|
||||
- [ ] **T029** [P] Create `PolicyNormalizerSettingsCatalogTest` test file
|
||||
- File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php`
|
||||
- Setup: Mock definition data, sample snapshot
|
||||
|
||||
- [ ] **T030** [P] Test grouping logic in normalizer
|
||||
- Input: Snapshot with settings from different categories
|
||||
- Assert: Groups created correctly
|
||||
- Assert: Groups sorted alphabetically
|
||||
- File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php`
|
||||
|
||||
- [ ] **T031** [P] Test value formatting in normalizer
|
||||
- Test bool → "Enabled"/"Disabled"
|
||||
- Test int → formatted number
|
||||
- Test string → truncation
|
||||
- Test choice → label extraction
|
||||
- File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php`
|
||||
|
||||
### Feature Tests (T032-T037)
|
||||
|
||||
- [ ] **T032** [P] Create `PolicyViewSettingsCatalogReadableTest` test file
|
||||
- File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php`
|
||||
- Setup: Mock GraphClient, create test policy with Settings Catalog type
|
||||
|
||||
- [ ] **T033** Test Settings Catalog policy view shows tabs
|
||||
- Navigate to Policy View
|
||||
- Assert: Tabs component present
|
||||
- Assert: "Settings" and "JSON" tabs visible
|
||||
- File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php`
|
||||
|
||||
- [ ] **T034** Test Settings tab shows display names (not definition IDs)
|
||||
- Mock: Definitions cached
|
||||
- Assert: Display names shown in UI
|
||||
- Assert: Definition IDs NOT visible
|
||||
- File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php`
|
||||
|
||||
- [ ] **T035** Test values formatted correctly
|
||||
- Mock: Settings with bool, int, string, choice values
|
||||
- Assert: Bool shows "Enabled"/"Disabled"
|
||||
- Assert: Int shows formatted number
|
||||
- Assert: String shows truncated value
|
||||
- File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php`
|
||||
|
||||
- [ ] **T036** [US2] Test search/filter functionality
|
||||
- Input: Type search query
|
||||
- Assert: Settings list filtered
|
||||
- Assert: Only matching settings shown
|
||||
- Assert: Clear search resets view
|
||||
- File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php`
|
||||
|
||||
- [ ] **T037** Test graceful degradation for missing definitions
|
||||
- Mock: Definitions not cached
|
||||
- Assert: Fallback labels shown (prettified IDs)
|
||||
- Assert: No broken layout
|
||||
- Assert: Info message visible
|
||||
- File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php`
|
||||
|
||||
### Validation & Polish (T038-T042)
|
||||
|
||||
- [ ] **T038** Run Pest test suite for Feature 185
|
||||
- Command: `./vendor/bin/sail artisan test --filter=SettingsCatalog`
|
||||
- Assert: All tests pass
|
||||
- Fix any failures
|
||||
|
||||
- [ ] **T039** Run Laravel Pint on modified files
|
||||
- Command: `./vendor/bin/sail pint --dirty`
|
||||
- Assert: No style issues
|
||||
- Commit fixes
|
||||
|
||||
- [ ] **T040** Review git changes for Feature 185
|
||||
- Check: No changes to forbidden areas (see constitution)
|
||||
- Verify: Only expected files modified
|
||||
- Document: List of changed files in research.md
|
||||
|
||||
- [ ] **T041** Run database migration on local environment
|
||||
- Command: `./vendor/bin/sail artisan migrate`
|
||||
- Verify: `settings_catalog_definitions` table created
|
||||
- Check: Indexes applied correctly
|
||||
|
||||
- [ ] **T042** Manual QA: Policy View with Settings Catalog policy
|
||||
- Navigate to Policy View for Settings Catalog policy
|
||||
- Verify: Tabs present ("Settings" and "JSON")
|
||||
- Verify: Settings tab shows accordion with groups
|
||||
- Verify: Display names shown (not raw IDs)
|
||||
- Verify: Values formatted correctly
|
||||
- Test: Search filters settings
|
||||
- Test: JSON tab works
|
||||
- Test: Copy buttons functional
|
||||
- Test: Dark mode toggle
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Sequential Dependencies
|
||||
- **Phase 1** → **Phase 2**: Database must exist before resolver can cache
|
||||
- **Phase 2** → **Phase 3**: Resolver must exist before snapshot enrichment
|
||||
- **Phase 2** → **Phase 4**: Definitions needed for normalizer
|
||||
- **Phase 4** → **Phase 5**: Normalized data structure needed for UI
|
||||
- **Phase 5** → **Phase 7**: UI must exist before feature tests
|
||||
|
||||
### Parallel Opportunities
|
||||
- **Phase 2** (T003-T007): Resolver methods can be implemented in parallel
|
||||
- **Phase 4** (T011-T014): Normalizer sub-methods can be implemented in parallel
|
||||
- **Phase 5** (T015-T022): UI components can be developed in parallel after T015
|
||||
- **Phase 7** (T026-T031): Unit tests can be written in parallel
|
||||
- **Phase 7** (T032-T037): Feature tests can be written in parallel
|
||||
|
||||
### Example Parallel Execution
|
||||
**Phase 2**:
|
||||
- Developer A: T003, T004 (resolve methods)
|
||||
- Developer B: T005, T006 (resolveOne + fallback)
|
||||
- Both converge for T007 (warmCache)
|
||||
|
||||
**Phase 5**:
|
||||
- Developer A: T015-T017 (tabs + accordion setup)
|
||||
- Developer B: T018-T020 (rendering logic)
|
||||
- Both converge for T021-T022 (search functionality)
|
||||
|
||||
---
|
||||
|
||||
## Task Complexity Estimates
|
||||
|
||||
| Phase | Task Count | Estimated Time | Dependencies |
|
||||
|-------|------------|----------------|--------------|
|
||||
| Phase 1: Database | 2 | ~30 min | None |
|
||||
| Phase 2: Resolver | 5 | ~2-3 hours | Phase 1 |
|
||||
| Phase 3: Snapshot | 3 | ~1 hour | Phase 2 |
|
||||
| Phase 4: Normalizer | 4 | ~2-3 hours | Phase 2 |
|
||||
| Phase 5: UI Settings | 8 | ~3-4 hours | Phase 4 |
|
||||
| Phase 6: UI Tabs | 3 | ~1 hour | Phase 5 |
|
||||
| Phase 7: Testing | 17 | ~3-4 hours | Phase 2-6 |
|
||||
| **Total** | **42** | **11-15 hours** | |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria Checklist
|
||||
|
||||
- [X] **SC-001**: Admin sees human-readable setting names (not definition IDs) on Policy View (Implementation complete - requires manual verification)
|
||||
- [X] **SC-002**: Setting values formatted appropriately (True/False, numbers, choice labels) (Implementation complete - requires manual verification)
|
||||
- [X] **SC-003**: Settings grouped by category with accordion (collapsible sections) (Implementation complete - requires manual verification)
|
||||
- [X] **SC-004**: Search/filter works on display name and value (<200ms response) (Blade-level implementation complete - requires manual verification)
|
||||
- [X] **SC-005**: Raw JSON available in separate "JSON" tab (Feature 002 integration preserved)
|
||||
- [X] **SC-006**: Unknown settings show prettified ID fallback (no broken layout) (Implementation complete - requires manual verification)
|
||||
- [ ] **SC-007**: Performance: <2s render for policy with 200 settings (Requires load testing)
|
||||
- [ ] **SC-008**: Tests pass: Unit tests for resolver + normalizer (Tests not written yet)
|
||||
- [ ] **SC-009**: Tests pass: Feature tests for UI rendering (Tests not written yet)
|
||||
- [ ] **SC-010**: Definition resolution: <500ms for batch of 50 (cached) (Requires benchmark testing)
|
||||
|
||||
---
|
||||
|
||||
## Constitution Compliance Evidence
|
||||
|
||||
| Principle | Evidence | Tasks |
|
||||
|-----------|----------|-------|
|
||||
| Safety-First | Read-only UI, no edit capabilities | All UI tasks |
|
||||
| Immutable Versioning | Snapshot enrichment non-blocking, metadata only | T008-T010 |
|
||||
| Defensive Restore | Not applicable (read-only feature) | N/A |
|
||||
| Auditability | Raw JSON still accessible via tab | T023-T025 |
|
||||
| Tenant-Aware | Resolver respects tenant scoping (via GraphClient) | T003-T007 |
|
||||
| Graph Abstraction | Uses existing GraphClientInterface | T003-T007 |
|
||||
| Spec-Driven | Full spec + plan + tasks before implementation | This document |
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation Tasks
|
||||
|
||||
- **Risk**: Graph API rate limiting
|
||||
- **Mitigation**: T007 (warmCache is non-blocking), aggressive DB caching
|
||||
- **Risk**: Definition schema changes by Microsoft
|
||||
- **Mitigation**: T001 (raw JSONB storage), T006 (fallback logic)
|
||||
- **Risk**: Large policies slow UI
|
||||
- **Mitigation**: T017-T018 (accordion lazy-loading), performance tests in T042
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
1. **Feature 002 Dependency**: Feature 185 uses tabs from Feature 002 JSON viewer implementation. Ensure Feature 002 code is stable before starting Phase 5.
|
||||
|
||||
2. **Database Migration**: Run migration early (T001) to avoid blocking later phases.
|
||||
|
||||
3. **Graph API Endpoints**: Verify access to `/deviceManagement/configurationSettings` endpoint in test environment before implementing T004.
|
||||
|
||||
4. **Testing Strategy**: Write unit tests (Phase 7, T026-T031) in parallel with implementation to enable TDD workflow.
|
||||
|
||||
5. **UI Polish**: Leave time for manual QA (T042) to catch UX issues not covered by automated tests.
|
||||
|
||||
6. **Performance Profiling**: Use Laravel Telescope or Debugbar during T042 to measure actual performance vs NFR targets.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Readiness
|
||||
|
||||
**Prerequisites**:
|
||||
- ✅ Feature 002 JSON viewer implemented (tabs pattern established)
|
||||
- ✅ pepperfm/filament-json installed
|
||||
- ✅ GraphClientInterface available
|
||||
- ✅ PolicyNormalizer exists
|
||||
- ✅ PolicySnapshotService exists
|
||||
- ✅ PostgreSQL with JSONB support
|
||||
|
||||
**Ready to Start**: Phase 1 (Database Foundation)
|
||||
@ -78,6 +78,10 @@
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Settings'); // Settings tab should appear for Settings Catalog
|
||||
$response->assertSee('General');
|
||||
$response->assertSee('JSON');
|
||||
$response->assertSee('tp-policy-general-card');
|
||||
$response->assertSee('Copy JSON');
|
||||
});
|
||||
|
||||
it('shows display names instead of definition IDs', function () {
|
||||
|
||||
@ -40,6 +40,10 @@
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'id' => 'scp-policy-1',
|
||||
'name' => 'Settings Catalog Policy',
|
||||
'platforms' => 'windows10',
|
||||
'technologies' => 'mdm',
|
||||
'settings' => [
|
||||
[
|
||||
'id' => 's1',
|
||||
@ -98,11 +102,7 @@
|
||||
$policyResponse->assertSee('device_vendor_msft_policy_config_system_minimumpinlength');
|
||||
$policyResponse->assertSee('12');
|
||||
$policyResponse->assertSee('SimpleSettingInstance');
|
||||
|
||||
$policyGeneralSection = [];
|
||||
preg_match('/<section[^>]*data-block="general"[^>]*>.*?<\/section>/is', $policyResponse->getContent(), $policyGeneralSection);
|
||||
expect($policyGeneralSection)->not->toBeEmpty();
|
||||
expect($policyGeneralSection[0])->toContain('x-cloak');
|
||||
$policyResponse->assertSee('tp-policy-general-card');
|
||||
|
||||
$versionResponse = $this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version]));
|
||||
|
||||
@ -434,7 +434,21 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
],
|
||||
);
|
||||
|
||||
$createResponse = new GraphResponse(
|
||||
$createFailResponse = new GraphResponse(
|
||||
success: false,
|
||||
data: ['error' => ['code' => 'NotSupported', 'message' => 'NotSupported']],
|
||||
status: 400,
|
||||
errors: [['code' => 'NotSupported', 'message' => 'NotSupported']],
|
||||
warnings: [],
|
||||
meta: [
|
||||
'error_code' => 'NotSupported',
|
||||
'error_message' => 'NotSupported',
|
||||
'request_id' => 'req-create-fail',
|
||||
'client_request_id' => 'client-create-fail',
|
||||
],
|
||||
);
|
||||
|
||||
$createSuccessResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: ['id' => 'new-policy-123'],
|
||||
status: 201,
|
||||
@ -443,7 +457,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
meta: ['request_id' => 'req-create', 'client_request_id' => 'client-create'],
|
||||
);
|
||||
|
||||
$client = new SettingsCatalogRestoreGraphClient($policyResponse, [$settingsResponse, $createResponse]);
|
||||
$client = new SettingsCatalogRestoreGraphClient($policyResponse, [$settingsResponse, $createFailResponse, $createSuccessResponse]);
|
||||
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
@ -513,11 +527,16 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($run->status)->toBe('partial');
|
||||
expect($run->results[0]['status'])->toBe('partial');
|
||||
expect($run->results[0]['created_policy_id'])->toBe('new-policy-123');
|
||||
expect($run->results[0]['created_policy_mode'])->toBe('metadata_only');
|
||||
expect($run->results[0]['settings_apply']['created_policy_id'])->toBe('new-policy-123');
|
||||
expect($run->results[0]['settings_apply']['created_policy_mode'])->toBe('metadata_only');
|
||||
|
||||
expect($client->requestCalls)->toHaveCount(2);
|
||||
expect($client->requestCalls)->toHaveCount(3);
|
||||
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-5/settings');
|
||||
expect($client->requestCalls[1]['path'])->toBe('deviceManagement/configurationPolicies');
|
||||
expect($client->requestCalls[1]['payload'])->toHaveKey('settings');
|
||||
expect($client->requestCalls[1]['payload'])->toHaveKey('name');
|
||||
expect($client->requestCalls[2]['path'])->toBe('deviceManagement/configurationPolicies');
|
||||
expect($client->requestCalls[2]['payload'])->not->toHaveKey('settings');
|
||||
expect($client->requestCalls[2]['payload'])->toHaveKey('name');
|
||||
});
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
$user = User::factory()->create();
|
||||
|
||||
$policyResponse = $this->actingAs($user)
|
||||
->get(PolicyResource::getUrl('view', ['record' => $policy]));
|
||||
->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings');
|
||||
|
||||
$policyResponse->assertOk();
|
||||
$policyResponse->assertSee('fi-width-full');
|
||||
|
||||
@ -5,7 +5,11 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
input: [
|
||||
'resources/css/app.css',
|
||||
'resources/css/filament/admin/theme.css',
|
||||
'resources/js/app.js',
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user