Dashboard
+License system overview
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddfc38f..6d4f2e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,18 +134,18 @@ jobs: run: | short_sha="${GITHUB_SHA:0:12}" mkdir -p release - bash scripts/package-release.sh v5.6.1 "$GITHUB_SHA" "release/Licora-5.6.1-ci-${short_sha}.zip" - cp "release/Licora-5.6.1-ci-${short_sha}.zip" "release/Licora-5.6.1.zip" - python3 scripts/build-update-manifest.py --version 5.6.1 --ref "$GITHUB_SHA" --package "release/Licora-5.6.1.zip" --output release/licora-update-manifest.json - rm "release/Licora-5.6.1.zip" + bash scripts/package-release.sh v5.7.1 "$GITHUB_SHA" "release/Licora-5.7.1-ci-${short_sha}.zip" + cp "release/Licora-5.7.1-ci-${short_sha}.zip" "release/Licora-5.7.1.zip" + python3 scripts/build-update-manifest.py --version 5.7.1 --ref "$GITHUB_SHA" --package "release/Licora-5.7.1.zip" --output release/licora-update-manifest.json + rm "release/Licora-5.7.1.zip" - name: Upload CI release-candidate artifact uses: actions/upload-artifact@v6 with: - name: Licora-5.6.1-${{ github.sha }} + name: Licora-5.7.1-${{ github.sha }} path: | - release/Licora-5.6.1-ci-*.zip - release/Licora-5.6.1-ci-*.zip.sha256 + release/Licora-5.7.1-ci-*.zip + release/Licora-5.7.1-ci-*.zip.sha256 release/licora-update-manifest.json if-no-files-found: error retention-days: 14 diff --git a/BASELINE_v5.7.0.md b/BASELINE_v5.7.0.md new file mode 100644 index 0000000..c8173a2 --- /dev/null +++ b/BASELINE_v5.7.0.md @@ -0,0 +1,53 @@ +# Licora v5.7.0 — Phase 2 Source Candidate Freeze + +## Parent Authority + +- Official parent baseline: `Licora-5.6.1.zip` +- Parent version/tag: `v5.6.1` +- Parent Git commit: `4b430b77ccc303aebeadc2852bebd3f11f67452a` +- Parent release ZIP SHA-256: `0ca0ad76b5c0091912aa441fcac4c033a54bac630d6c1a7255ac5b2b75db5493` +- Parent release manifest source: `v5.6.1` + +The parent baseline is immutable. v5.7.0 is produced in an isolated work copy and does not rewrite the v5.6.1 freeze. + +## Candidate Identity + +- Target version: `5.7.0` +- Scope: Dashboard Phase 2 only +- Database migration: `NONE` +- Deleted files: `NONE` +- External API contract change: `NONE` +- Dashboard backend/read-model contract change: `NONE` +- License/device enforcement change: `NONE` +- Authentication/role change: `NONE` +- Cron mutation change: `NONE` +- Updater protocol/state-machine change: `NONE` +- Shared sidebar/topbar redesign: `NONE` + +## Phase 2 Runtime Scope + +1. compact Dashboard operations composition; +2. measured system-status strip; +3. four primary truthful KPI cards; +4. dedicated `admin/assets/js/dashboard.js` browser controller; +5. authenticated 30-second AJAX refresh through the existing Phase 1 endpoint; +6. manual refresh and last-updated feedback; +7. in-place Chart.js/KPI/activity/top-license updates; +8. request-overlap protection; +9. stale-data preservation/Retry state and auth-expiry polling shutdown; +10. responsive/accessibility behavior and dedicated Phase 2 tests. + +## Acceptance State + +Current source-candidate evidence: + +- full local verifier: `PASS`; +- targeted Phase 2 contract/browser-runtime tests: `PASS`; + +The candidate must not be treated as a published baseline until the remaining required gates have evidence: + +- required remote CI/MySQL matrix after an authorized push; +- manual desktop/tablet/mobile production/staging smoke; +- authorized merge/tag/release workflow. + +The exact v5.7.0 commit SHA does not exist until the user commits the reviewed delta. diff --git a/BASELINE_v5.7.1.md b/BASELINE_v5.7.1.md new file mode 100644 index 0000000..1a4ce6f --- /dev/null +++ b/BASELINE_v5.7.1.md @@ -0,0 +1,51 @@ +# Licora v5.7.1 — Dashboard Phase 2 Corrective Source Freeze + +## Parent Authority + +- Official parent baseline: `Licora_v5.7.0_Baseline.zip` +- Parent baseline SHA-256: `e198fda3a90f38ef0d15faeab3f0b2797b92ba98b542cb7f22ac8f01b3bda022` +- Parent embedded Git HEAD: `4b430b77ccc303aebeadc2852bebd3f11f67452a` +- Parent source version: `5.7.0` +- Parent GitHub tag status at audit time: `NOT PUBLISHED` +- Parent Phase 2 state: source implemented + locally verified; remote/live acceptance pending + +The uploaded v5.7.0 archive is immutable forensic authority. v5.7.1 is produced only in an isolated work copy. + +## Corrective Scope Lock + +Only the latest Phase 2 verification/fix scope is changed: + +1. preserve `Retry` after failed-refresh cleanup; +2. preserve the 401/session-expiry refresh lock and `Refresh paused` UI; +3. capture synchronous request transport failures in the normal stale/error lifecycle; +4. advance `lastSuccessAt` only after successful render completion; +5. extend Dashboard browser/runtime tests for those four cases; +6. align current source/release/docs identity to `5.7.1`. + +## Frozen / Unchanged + +- Dashboard read model and authenticated JSON endpoint +- database schema and migration set +- API v1/v2 request/response/auth/crypto contracts +- license/device state/enforcement +- authentication/roles/session policy +- Cron mutation behavior +- updater protocol/signing/rollback/state machine +- shared sidebar/topbar and non-Dashboard admin UI +- Phase 2 layout, KPI definitions, chart semantics, polling cadence and existing navigation routes + +## Chrome/Browser Finding + +No Chrome launcher/downloader implementation exists in the v5.7.0 baseline application runtime. No browser-specific downloader is introduced in v5.7.1. + +## Acceptance State + +The full `python3 scripts/verify-local.py` gate passed after corrective source/tests/version/documentation alignment. Remote CI/MySQL and manual live/staging UI smoke remain required before Phase 2 is marked final `COMPLETE + VERIFIED`. + +## Release Compatibility + +The v5.7.1 signed update specification accepts `5.6.1` and `5.7.0`, with no migrations and no delete list. This allows direct upgrade from the published v5.6.1 release while also supporting installations where the v5.7.0 source baseline was already applied. + +## Source Freeze Packaging + +The v5.7.1 source baseline freeze is packaged from the verified work tree with repository `.git` metadata excluded. This avoids treating an uncommitted local Git object database as product source. The external baseline ZIP SHA-256 is recorded alongside the delivered artifact; the authoritative Git commit SHA will be established only after the user performs the reviewed commit/push flow. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5ef28..3d8b891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,36 @@ All notable public-release changes are recorded here. Historical project notes r ### Planned -- Continue the reviewed Dashboard production program with Phase 2: compact UI and reload-free refresh. +- No additional Dashboard scope is approved beyond the v5.7.1 Phase 2 corrective release candidate. + +## [5.7.1] - 2026-08-20 + +### Fixed +- Preserved the Dashboard `Retry` label after failed-refresh loading cleanup instead of resetting it to `Refresh`. +- Preserved the `401 AUTH_REQUIRED` refresh lock and `Refresh paused` state after request cleanup. +- Routed synchronous Dashboard request-transport exceptions through the normal stale/error lifecycle so the in-flight lock and loading state are always released. +- Advanced the Dashboard last-success timestamp only after a snapshot completes rendering successfully. +- Expanded Dashboard browser/runtime regression coverage for the corrected stale/auth/transport/render lifecycle. + +### Compatibility +- No database migration, file deletion, backend Dashboard contract change, external API change, license/device enforcement change, authentication/role change, Cron mutation change, updater protocol change, shared shell change or new browser dependency. +- Signed update compatibility accepts both published `v5.6.1` and an already-applied `v5.7.0` source baseline. + +## [5.7.0] - 2026-08-20 (source baseline; superseded by 5.7.1 corrective) + +### Changed +- Rebuilt the Dashboard as a compact operations view while preserving the existing Licora light shell, sidebar, topbar and other admin pages. +- Replaced the 30-second full-page Dashboard reload with authenticated 30-second AJAX polling against the existing read-only `admin/ajax/dashboard-data.php` contract. +- Added manual refresh, last-updated feedback, stale-data handling, session-expiry handling and request-overlap protection without changing backend business truth. +- Updated API and expiration charts in place, combined source-labelled API v1/v2 recent activity, and converted Quick Actions to compact links over existing routes. + +### Added +- Added `admin/assets/js/dashboard.js` as the dedicated Dashboard refresh/controller layer. +- Added Phase 2 source/DOM contract and browser-runtime tests covering polling, manual refresh, overlap prevention, stale behavior and auth expiry. + +### Compatibility +- No database migration, file deletion, external API contract change, license/device enforcement change, authentication/role change, Cron mutation change, updater protocol change, installer schema change, sidebar/topbar redesign or shared application architecture change. +- Signed update compatibility starts from the frozen official `v5.6.1` baseline. ## [5.6.1] - 2026-08-20 diff --git a/DASHBOARD_PRODUCTION_UPDATE_INDEX.md b/DASHBOARD_PRODUCTION_UPDATE_INDEX.md index 28b2ac8..e82eb87 100644 --- a/DASHBOARD_PRODUCTION_UPDATE_INDEX.md +++ b/DASHBOARD_PRODUCTION_UPDATE_INDEX.md @@ -2,18 +2,17 @@ ## Authority -এই documentation program v5.5.1 থেকে শুরু হয়েছিল; বর্তমান corrective source authority হলো uploaded **Licora v5.6.0 Official Baseline Freeze**। +এই documentation program v5.5.1 থেকে শুরু হয়েছিল; বর্তমান immutable source authority হলো uploaded **Licora v5.7.0 Official Baseline Freeze**। -- Current baseline ZIP SHA-256: `ba99c6e4fd74c2b59d392c3010b3aefe493390b3b0b0c94bd3c211218f14d597` -- Current baseline Git commit: `5c685636e955422bc70e3bf07694f55d9c7fb1dc` -- Current baseline Git tree: `848801c1785ebba0b2523a34afcf6af3ee05d5d6` -- Current baseline version: `5.6.0` +- Current baseline ZIP SHA-256: `e198fda3a90f38ef0d15faeab3f0b2797b92ba98b542cb7f22ac8f01b3bda022` +- Current baseline embedded Git HEAD: `4b430b77ccc303aebeadc2852bebd3f11f67452a` +- Current baseline version: `5.7.0` - Original Phase-program baseline: `v5.5.1 / 2f48ef569e6c532ab0de974a418c644e4ea8423f` -- Development implementation status: **v5.6.1 Phase 1 COMPLETE + VERIFIED — PR #8 Actions run 32423210356 PASS** +- Development implementation status: **v5.7.1 Phase 2 corrective source + local verification PASS — remote CI/live acceptance gates pending** - Planned update phases: **2** -- Current corrective target: **v5.6.1 — Phase 1 verified baseline; Phase 2 NOT STARTED** +- Current target: **v5.7.1 — Phase 2 verification corrective candidate** -এই document set Phase 1 runtime implementation এবং v5.6.1 corrective verification state record করে। v5.6.1 কোনো database migration, external API contract change, license/device enforcement change, Cron mutation change, updater protocol change বা Phase 2 polling/UI feature যোগ করে না। +এই document set Phase 1 verified foundation, v5.7.0 Phase 2 source baseline এবং v5.7.1 corrective verification candidate record করে। Phase 2 Dashboard presentation/browser behavior পরিবর্তন করে, কিন্তু database schema, external API contracts, license/device enforcement, authentication/roles, Cron mutation behavior, updater protocol এবং shared sidebar/topbar architecture অপরিবর্তিত রাখে। ## Document Map diff --git a/README.md b/README.md index 749738e..b2554e8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Licora is maintained by **Vib Tools**. Vib Tools is a professional tools and dig - Device registration, activity tracking, revocation, blacklist handling, and risk indicators. - Role-aware admin panel for super administrators, managers, and viewers. - Audit trail, operational logs, CSV exports, SQL backup generation, and health checks. +- Reload-free operational Dashboard with truthful license/device/API/expiration metrics, manual refresh, stale-data feedback, and source-separated API activity. - CSRF tokens for admin mutations, prepared SQL statements, password hashing, rate limiting, and session hardening. - Scheduled cleanup and expiring-license reporting through CLI cron scripts. - Super-Admin-only Secure Update Center with signed GitHub release manifests, preflight, staged installation, persistent live logs, migration tracking, and rollback protection. @@ -124,7 +125,7 @@ The application accepts deployment-specific values through environment variables | Database password | `LICENSE_DB_PASS` | empty | | Application name | `APP_NAME` | `Licora` | | Application URL | `APP_URL` | `http://localhost` | -| Application version | `APP_VERSION` | `5.6.1` | +| Application version | `APP_VERSION` | `5.7.1` | | Environment | `APP_ENV` | `production` | | Encryption key | `LICENSE_ENCRYPTION_KEY` | empty fallback | | API limit | `API_RATE_LIMIT` | `1000` | @@ -155,6 +156,8 @@ The validation script checks PHP syntax, security behavior, compatibility invari - [Secure in-app updater](docs/UPDATER.md) - [UI design system](docs/UI_DESIGN_SYSTEM.md) - [Troubleshooting](docs/TROUBLESHOOTING.md) +- [v5.7.1 release notes](RELEASE_NOTES_v5.7.1.md) +- [v5.7.0 release notes](RELEASE_NOTES_v5.7.0.md) - [v5.6.1 release notes](RELEASE_NOTES_v5.6.1.md) - [v5.6.0 release notes](RELEASE_NOTES_v5.6.0.md) - [v5.5.1 release notes](RELEASE_NOTES_v5.5.1.md) @@ -173,9 +176,17 @@ The validation script checks PHP syntax, security behavior, compatibility invari - [Dependency review](audit/DEPENDENCY_REPORT.md) +## Dashboard Phase 2 verification corrective candidate (v5.7.1) + +Licora v5.7.1 corrects the client-side refresh lifecycle found during forensic review of the v5.7.0 Phase 2 source baseline: stale refresh keeps `Retry`, `401 AUTH_REQUIRED` keeps refresh paused/disabled, synchronous transport errors are captured without leaving the request lock stuck, and the last-success timestamp advances only after a successful render. Phase 2 layout/data semantics, backend contracts, database schema, APIs, licensing/device enforcement, authentication, Cron behavior, updater protocol and shared shell remain unchanged. + +## Dashboard Phase 2 source baseline (v5.7.0) + +Licora v5.7.0 builds on the frozen/published v5.6.1 Phase 1 baseline. The Dashboard keeps its server-rendered initial snapshot but replaces the former full-page 30-second reload with authenticated 30-second AJAX refresh, manual Refresh, last-updated/stale/session feedback, request-overlap protection and in-place chart/KPI/activity updates. Phase 1 backend data semantics, database schema, APIs, license/device enforcement, authentication/roles, Cron mutation behavior and updater protocol remain unchanged. + ## Phase 1 verification corrective update (v5.6.1) -Licora v5.6.1 is a no-migration corrective update over the applied v5.6.0 Phase 1 source baseline; v5.6.0 was not published as a GitHub tag/release and is superseded by v5.6.1. It fixes the MySQL integration-test foreign-key cleanup exposed by PR #8 CI, aligns the authenticated Dashboard JSON envelope with its documented top-level `recent_activity` field, and makes the Dashboard API v2 `Ready` status depend on a verified matching server signing key pair rather than the public key alone. Licora remains a browser-based PHP application and contains no Google Chrome installer/downloader dependency. Phase 2 reload-free polling/UI work remains unimplemented. +Licora v5.6.1 is a no-migration corrective update over the applied v5.6.0 Phase 1 source baseline; v5.6.0 was not published as a GitHub tag/release and is superseded by v5.6.1. It fixes the MySQL integration-test foreign-key cleanup exposed by PR #8 CI, aligns the authenticated Dashboard JSON envelope with its documented top-level `recent_activity` field, and makes the Dashboard API v2 `Ready` status depend on a verified matching server signing key pair rather than the public key alone. Licora remains a browser-based PHP application and contains no Google Chrome installer/downloader dependency. At the v5.6.1 baseline, Phase 2 had not yet been implemented; the separately scoped v5.7.0 source baseline added that browser refresh layer without changing the Phase 1 backend contract. ## Dashboard data truth and read model (v5.6.0) diff --git a/RELEASE_COMMANDS_v5.7.0.md b/RELEASE_COMMANDS_v5.7.0.md new file mode 100644 index 0000000..98e25ef --- /dev/null +++ b/RELEASE_COMMANDS_v5.7.0.md @@ -0,0 +1,68 @@ +# Licora v5.7.0 — Phase 2 Review / Publication Commands + +This file records the reviewed Windows/CMD sequence for the v5.7.0 Dashboard Phase 2 delta. It does not authorize GitHub writes by itself. + +## Preconditions + +- Official parent baseline: published `v5.6.1` +- Parent commit: `4b430b77ccc303aebeadc2852bebd3f11f67452a` +- Parent release ZIP SHA-256: `0ca0ad76b5c0091912aa441fcac4c033a54bac630d6c1a7255ac5b2b75db5493` +- Apply the replace-ready delta only to a clean checkout of the reviewed parent baseline/main. +- Stop on any unexpected file, error, failed check or merge conflict. + +## Create the Phase 2 branch + +```cmd +git switch main +git pull --ff-only origin main +git status --short --branch +git switch -c feature/v5.7.0-dashboard-phase2 +``` + +After extracting the reviewed delta into the repository root: + +```cmd +git diff --check +git status --short +``` + +Only the reviewed v5.7.0 delta paths should appear. + +## Exact staging command + +Never use `git add -A`, `git add .` or `git add --all` for this release. + +```cmd +git add -- .github/workflows/ci.yml BASELINE_v5.7.0.md CHANGELOG.md DASHBOARD_PRODUCTION_UPDATE_INDEX.md README.md RELEASE_COMMANDS_v5.7.0.md RELEASE_NOTES_v5.7.0.md REPOSITORY_METADATA.md ROADMAP.md admin/assets/css/admin-ui.css admin/assets/js/dashboard.js admin/index.php audit/V5.7.0_DASHBOARD_PHASE2_AUDIT.md audit/V5.7.0_DELTA_MANIFEST.md audit/V5.7.0_DELTA_SHA256SUMS.txt config.sample.php docs/ACTUAL_IMPLEMENTATION_LEDGER.md docs/ARCHITECTURE.md docs/CONFIGURATION.md docs/DASHBOARD_CHANGE_CONTROL.md docs/DASHBOARD_DATA_CONTRACT.md docs/DASHBOARD_PRODUCTION_VALIDATION_GATES.md docs/DASHBOARD_UPDATE_PHASE_LOG.md docs/ERROR_HANDLING_BASELINE_AND_TARGET.md docs/FEATURE_MATRIX.md docs/INSTALLATION.md docs/RELEASE.md docs/UI_DESIGN_SYSTEM.md docs/UPGRADE_GUIDE.md includes/config.php includes/installation.php install.php scripts/verify-local.py tests/compatibility_regression.php tests/dashboard_browser_runtime.js tests/dashboard_data_contract.php tests/dashboard_phase2_contract.php tests/installer_smoke.php tests/release_readiness.php tests/updater_state_machine.php update/release-spec.json +``` + +Then inspect exactly what is staged: + +```cmd +git diff --cached --check +git diff --cached --name-status +git status +``` + +## Commit / push — only after explicit GitHub-write authorization + +```cmd +git commit -m "feat: add reload-free compact Dashboard in Licora v5.7.0" +git push -u origin feature/v5.7.0-dashboard-phase2 +``` + +Create/review a PR targeting `main`, then run the required CI once. Do not merge on a failing/pending gate. + +## Tag / release — only after Phase 2 final acceptance and explicit authorization + +Do not tag until the PR is merged, `main` is clean/synchronized, remote CI is green, and the required manual production/staging Dashboard smoke is accepted. + +```cmd +git switch main +git pull --ff-only origin main +git status --short --branch +git tag -a v5.7.0 -m "Licora v5.7.0 - Compact Dashboard and Reload-Free Refresh" +git push origin v5.7.0 +``` + +The tag-triggered release workflow must then build/verify the exact-tag ZIP, run the required database gates, build/sign the update manifest and publish the GitHub Release. diff --git a/RELEASE_COMMANDS_v5.7.1.md b/RELEASE_COMMANDS_v5.7.1.md new file mode 100644 index 0000000..1b3f5e3 --- /dev/null +++ b/RELEASE_COMMANDS_v5.7.1.md @@ -0,0 +1,111 @@ +# Licora v5.7.1 — Phase 2 Verification Fix Review / Publication Commands + +This file records the reviewed Windows/CMD sequence for the v5.7.1 corrective candidate. It does not authorize GitHub writes by itself. + +## Source authority + +- Official corrective parent: uploaded `Licora_v5.7.0_Baseline.zip` +- Parent ZIP SHA-256: `e198fda3a90f38ef0d15faeab3f0b2797b92ba98b542cb7f22ac8f01b3bda022` +- Parent embedded Git HEAD: `4b430b77ccc303aebeadc2852bebd3f11f67452a` +- Parent local branch: `feature/v5.7.0-dashboard-phase2` +- Parent GitHub v5.7.0 tag: not published at audit time +- Target: `v5.7.1` +- Replace-ready corrective delta: `37 paths` relative to the uploaded v5.7.0 baseline +- Combined Git publication scope: `47 paths` relative to Git HEAD `4b430b7` +- Delete list: none +- Database migrations: none + +## Why Git publication has 47 paths + +The uploaded v5.7.0 baseline contains the original Phase 2 implementation as an **uncommitted** 41-path work-tree delta over the published v5.6.1 commit. v5.7.1 then corrects that baseline and adds corrective/version/audit files. + +Therefore: + +- the delivered v5.7.1 replace-ready delta contains only the **37 paths that differ from the uploaded v5.7.0 baseline**; +- but the Git commit that publishes Phase 2 must include the **full 47-path combined work tree** relative to the current Git HEAD, otherwise original Phase 2 files would be omitted. + +Do not use `git add -A`, `git add .` or `git add --all`. + +## Apply v5.7.1 corrective delta + +Stay on the existing Phase 2 branch: + +```cmd +git status --short --branch +``` + +Expected branch: + +```text +feature/v5.7.0-dashboard-phase2 +``` + +Extract `Licora-v5.7.1-Dashboard-Phase2-Verification-Fix-Delta.zip` over the repository root and overwrite matching files. + +Then: + +```cmd +git diff --check +git status --short +``` + +The combined status should contain exactly 47 reviewed paths: 32 modified and 15 untracked. + +## Exact combined staging command + +```cmd +git add -- .github/workflows/ci.yml BASELINE_v5.7.0.md BASELINE_v5.7.1.md CHANGELOG.md DASHBOARD_PRODUCTION_UPDATE_INDEX.md README.md RELEASE_COMMANDS_v5.7.0.md RELEASE_COMMANDS_v5.7.1.md RELEASE_NOTES_v5.7.0.md RELEASE_NOTES_v5.7.1.md REPOSITORY_METADATA.md ROADMAP.md admin/assets/css/admin-ui.css admin/assets/js/dashboard.js admin/index.php audit/V5.7.0_DASHBOARD_PHASE2_AUDIT.md audit/V5.7.0_DELTA_MANIFEST.md audit/V5.7.0_DELTA_SHA256SUMS.txt audit/V5.7.1_DASHBOARD_PHASE2_VERIFICATION_AUDIT.md audit/V5.7.1_DELTA_MANIFEST.md audit/V5.7.1_DELTA_SHA256SUMS.txt config.sample.php docs/ACTUAL_IMPLEMENTATION_LEDGER.md docs/ARCHITECTURE.md docs/CONFIGURATION.md docs/DASHBOARD_CHANGE_CONTROL.md docs/DASHBOARD_DATA_CONTRACT.md docs/DASHBOARD_PRODUCTION_VALIDATION_GATES.md docs/DASHBOARD_UPDATE_PHASE_LOG.md docs/ERROR_HANDLING_BASELINE_AND_TARGET.md docs/FEATURE_MATRIX.md docs/INSTALLATION.md docs/RELEASE.md docs/UI_DESIGN_SYSTEM.md docs/UPGRADE_GUIDE.md includes/config.php includes/installation.php install.php scripts/verify-local.py tests/compatibility_regression.php tests/dashboard_browser_runtime.js tests/dashboard_data_contract.php tests/dashboard_phase2_contract.php tests/installer_smoke.php tests/release_readiness.php tests/updater_state_machine.php update/release-spec.json +``` + +Then inspect once: + +```cmd +git diff --cached --check +git diff --cached --name-status +git status +``` + +Expected: exactly 47 staged paths and no unstaged Phase 2/v5.7.1 paths. + +## Commit / push — only after explicit GitHub-write authorization + +```cmd +git commit -m "feat: complete Dashboard Phase 2 with v5.7.1 verification fixes" +git push -u origin feature/v5.7.0-dashboard-phase2 +``` + +Recommended PR title: + +```text +Licora v5.7.1 - Dashboard Phase 2 Complete with Verification Fixes +``` + +Recommended PR body summary: + +```text +Implements the approved Dashboard Phase 2 compact/reload-free UI over the published v5.6.1 base and includes the v5.7.1 corrective refresh-lifecycle fixes found during forensic verification. No database migration, file deletion, external API contract change, license/device enforcement change, auth/role change, Cron mutation change, updater protocol change, or shared sidebar/topbar redesign. +``` + +After push, run the PR CI once. Do not merge while any required gate is pending or failing. + +## Tag / release — only after final Phase 2 acceptance and explicit authorization + +Do not tag until: + +1. PR CI/MySQL/PHP/Windows gates are green; +2. required manual desktop/tablet/mobile staging or production smoke is accepted; +3. PR is merged; +4. local `main` is clean and synchronized. + +Then: + +```cmd +git switch main +git pull --ff-only origin main +git status --short --branch +git tag -a v5.7.1 -m "Licora v5.7.1 - Dashboard Phase 2 Complete and Verified" +git show --no-patch --decorate v5.7.1 +git push origin v5.7.1 +``` + +The tag-triggered release workflow must verify the exact tag, run the database gate, build the exact-tag ZIP/checksum, build/sign/verify the updater manifest, and publish the GitHub Release. diff --git a/RELEASE_NOTES_v5.7.0.md b/RELEASE_NOTES_v5.7.0.md new file mode 100644 index 0000000..0aaf6a1 --- /dev/null +++ b/RELEASE_NOTES_v5.7.0.md @@ -0,0 +1,51 @@ +# Licora v5.7.0 — Compact Dashboard & Reload-Free Refresh + +**Release type:** Dashboard Phase 2 UI/runtime release candidate +**Official parent baseline:** v5.6.1 / `4b430b77ccc303aebeadc2852bebd3f11f67452a` +**Database migration:** None +**Deleted files:** None +**External API v1/v2 contracts:** Unchanged +**License/device enforcement:** Unchanged +**Authentication/roles:** Unchanged +**Cron mutation behavior:** Unchanged +**Updater protocol/state machine:** Unchanged +**Shared sidebar/topbar:** Unchanged + +## Dashboard Phase 2 + +v5.7.0 keeps the verified v5.6.1 Dashboard read model/error contract and changes only the approved Dashboard presentation/browser interaction layer. + +### Changed + +- Rebuilt `admin/index.php` as a compact operations Dashboard while retaining server-rendered initial content. +- Removed the former 30-second `window.location.reload()` behavior. +- Added authenticated 30-second AJAX refresh through the existing GET-only `admin/ajax/dashboard-data.php` endpoint. +- Added manual Refresh and a last-successful-update indicator. +- Updates license/device KPIs, health facts, API/expiration charts, recent activity and top API v1 licenses in place. +- Combines recent API v1 calls and API v2 audit events in one source-labelled operational activity view without changing the backend source distinction. +- Replaces large Quick Action tiles with compact links to existing admin routes. + +### Error and session behavior + +- Prevents overlapping poll/manual requests with an in-flight lock. +- Refresh failures preserve the last successfully rendered data and show a stale-data/Retry state instead of replacing values with zero or blank content. +- An endpoint `401 AUTH_REQUIRED` pauses polling and surfaces the existing sign-in path. +- No backend exception/credential/key detail is exposed by the browser controller. + +### Accessibility and responsive behavior + +- Refresh is a real keyboard-accessible button. +- Refresh/stale/auth feedback uses an ARIA live status region. +- System facts include visible text in addition to color indicators. +- Dashboard layout collapses for tablet/mobile while the existing shared shell remains unchanged. +- Refresh animation respects `prefers-reduced-motion`. + +## Verification added + +- `tests/dashboard_phase2_contract.php` verifies Dashboard DOM/scope/runtime contract and the frozen Phase 1 endpoint boundary. +- `tests/dashboard_browser_runtime.js` verifies polling cadence, manual refresh, overlap prevention, successful rendering lifecycle, stale preservation and 401 polling shutdown. +- Existing Dashboard data/DB, API, installer, security, updater and UI regression gates remain required. + +## Compatibility + +v5.7.0 declares an empty migration list and empty delete list. The signed update source is the frozen official `v5.6.1` baseline. No new database table/column, external API response/request field, license/device state transition, auth policy, Cron write behavior or updater protocol is introduced. diff --git a/RELEASE_NOTES_v5.7.1.md b/RELEASE_NOTES_v5.7.1.md new file mode 100644 index 0000000..9d6307e --- /dev/null +++ b/RELEASE_NOTES_v5.7.1.md @@ -0,0 +1,55 @@ +# Licora v5.7.1 — Dashboard Phase 2 Verification Fix + +**Release type:** Corrective Dashboard Phase 2 verification release candidate +**Official source baseline:** `Licora_v5.7.0_Baseline.zip` +**Baseline ZIP SHA-256:** `e198fda3a90f38ef0d15faeab3f0b2797b92ba98b542cb7f22ac8f01b3bda022` +**Embedded Git HEAD:** `4b430b77ccc303aebeadc2852bebd3f11f67452a` +**Database migration:** None +**Deleted files:** None +**External API v1/v2 contracts:** Unchanged +**License/device enforcement:** Unchanged +**Authentication/roles:** Unchanged +**Cron mutation behavior:** Unchanged +**Updater protocol/state machine:** Unchanged +**Shared sidebar/topbar:** Unchanged + +## Why v5.7.1 exists + +The v5.7.0 Phase 2 source candidate passed the complete local verifier, but a manual forensic review of the Dashboard refresh lifecycle found four client-side state-management defects that were not covered by the original browser-runtime test. + +### Fixed + +1. **Stale refresh label preservation** — a failed refresh now keeps the reviewed `Retry` label after loading cleanup instead of being overwritten back to `Refresh`. +2. **401/auth lock preservation** — `AUTH_REQUIRED` now keeps manual refresh disabled and preserves `Refresh paused` after the request `finally` path runs. +3. **Synchronous transport failure recovery** — a synchronous request/transport exception is now captured by the normal Promise error path, releases the in-flight lock, clears loading state and surfaces stale data rather than escaping and leaving the Dashboard stuck. +4. **Last-success timestamp correctness** — `lastSuccessAt` now advances only after the new snapshot finishes rendering successfully. A render failure retains the prior successful timestamp. + +The Dashboard browser-runtime regression test was expanded to exercise each corrective case. + +## Phase 2 behavior retained + +- compact operational Dashboard composition; +- server-rendered initial snapshot; +- authenticated 30-second AJAX polling through the unchanged Phase 1 endpoint; +- manual Refresh; +- in-place KPI, measured health, Chart.js, recent-activity and top-license updates; +- overlap protection; +- stale-data preservation; +- responsive/accessibility behavior. + +## Browser portability finding + +Licora is server-rendered PHP web software. The audited v5.7.0 baseline contains no Google Chrome executable launcher, Chrome installer/downloader, or Google Chrome download URL in the application runtime. The reported “Chrome unavailable → Chrome download failed” behavior therefore is not produced by this Licora source tree. No speculative browser downloader is added. The existing verifier guard against a Chrome-specific runtime dependency remains active. + +## Update compatibility + +`v5.7.0` is not present as a published GitHub tag on the audited repository at the time of this corrective preparation. The v5.7.1 release specification therefore accepts both: + +- published `v5.6.1`; and +- an already-applied `v5.7.0` source baseline. + +This corrective release has an empty migration list and empty delete list. + +## Remaining acceptance gates + +Source/local verification can be completed before publication. Phase 2 must not be marked final `COMPLETE + VERIFIED` until the authorized remote CI/MySQL matrix and the required desktop/tablet/mobile live or staging smoke are accepted. diff --git a/REPOSITORY_METADATA.md b/REPOSITORY_METADATA.md index 4ef8f2e..8951382 100644 --- a/REPOSITORY_METADATA.md +++ b/REPOSITORY_METADATA.md @@ -27,22 +27,47 @@ Licora v5.1.0 adds a ten-step first-run installer, delimiter-aware schema execution, secure administrator and secret generation, atomic private configuration, installation locking, optional demo data, database-port support, safe installer diagnostics, and expanded regression validation. The release preserves the existing license engine, API contracts, database schema, routes, admin UI, cron entry points, and encrypted-data compatibility. -## v5.6.1 release candidate +## v5.7.1 corrective release candidate -- **Intended tag:** `v5.6.1` -- **Publication status:** pending corrected PR #8 CI, merge, and tag publication +- **Intended tag:** `v5.7.1` +- **Publication status:** source candidate only; GitHub write/release not yet authorized +- **Title:** `Licora v5.7.1 — Dashboard Phase 2 Verification Fix` +- **Release notes:** `RELEASE_NOTES_v5.7.1.md` +- **Primary assets:** `Licora-5.7.1.zip`, `.zip.sha256`, `licora-update-manifest.json`, `licora-update-manifest.sig` +- **Database migration:** none +- **Delete list:** empty +- **Supported update sources:** `v5.6.1`, applied `v5.7.0` + +Licora v5.7.1 preserves the v5.7.0 compact/reload-free Dashboard scope and corrects four refresh-lifecycle defects: stale Retry label persistence, 401 refresh-lock persistence, synchronous transport-error capture, and last-success timestamp advancement only after successful render. + +## v5.7.0 source baseline + +- **Intended tag:** `v5.7.0` +- **Publication status:** not published as a GitHub tag; uploaded source baseline is superseded by the v5.7.1 corrective candidate +- **Title:** `Licora v5.7.0 — Compact Dashboard & Reload-Free Refresh` +- **Release notes:** `RELEASE_NOTES_v5.7.0.md` +- **Primary assets:** `Licora-5.7.0.zip`, `.zip.sha256`, `licora-update-manifest.json`, `licora-update-manifest.sig` +- **Database migration:** none +- **Delete list:** empty +- **Supported update source:** `v5.6.1` + +Licora v5.7.0 implements Dashboard Phase 2 over the frozen v5.6.1 release: compact operational composition, authenticated reload-free polling, manual refresh, last-updated/stale/auth feedback, in-place chart/activity updates and request-overlap protection. Backend data truth, APIs, licensing/device enforcement, schema, Cron mutation behavior and updater protocol remain unchanged. + +## v5.6.1 published baseline + +- **Tag:** `v5.6.1` +- **Commit:** `4b430b77ccc303aebeadc2852bebd3f11f67452a` +- **Publication status:** published and frozen as the official Phase 2 parent baseline - **Title:** `Licora v5.6.1 — Dashboard Phase 1 Verification Fix` - **Release notes:** `RELEASE_NOTES_v5.6.1.md` - **Primary assets:** `Licora-5.6.1.zip`, `.zip.sha256`, `licora-update-manifest.json`, `licora-update-manifest.sig` - **Database migration:** none -- **Delete list:** empty -- **Supported update sources:** `v5.5.1`, `v5.6.0` -Licora v5.6.1 corrects the Phase 1 DB integration fixture, restores exact Dashboard JSON/data-contract parity, and makes API v2 Dashboard readiness require a valid matching signing key pair. Phase 2 remains pending. +Licora v5.6.1 is the verified Phase 1 baseline. Its release ZIP SHA-256 is `0ca0ad76b5c0091912aa441fcac4c033a54bac630d6c1a7255ac5b2b75db5493`. ## v5.6.0 source baseline (not published) -- **Tag status:** `v5.6.0` was not published on GitHub; this source baseline is superseded by the v5.6.1 corrective candidate +- **Tag status:** `v5.6.0` was not published on GitHub; this source baseline is superseded by the published v5.6.1 corrective release - **Title:** `Licora v5.6.0 — Dashboard Data Truth & Read Model` - **Release notes:** `RELEASE_NOTES_v5.6.0.md` - **Primary assets:** `Licora-5.6.0.zip`, `.zip.sha256`, `licora-update-manifest.json`, `licora-update-manifest.sig` diff --git a/ROADMAP.md b/ROADMAP.md index 931de86..0b5fdfa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,9 @@ # Roadmap -## Dashboard production program (v5.6.x) +## Dashboard production program (v5.6.1 → v5.7.1) -- **Phase 1 — Data Truth, Backend Read Model & Error Contract:** implemented in v5.6.0 and corrected in v5.6.1 after PR #8 exposed a MySQL fixture FK-order failure and contract/readiness mismatches; final remote DB/CI verification remains the release gate. -- **Phase 2 — Compact UI, Reload-Free Refresh & Production Gate:** pending; no Phase 2 polling/UI implementation is included in v5.6.1. +- **Phase 1 — Data Truth, Backend Read Model & Error Contract:** COMPLETE + VERIFIED in the published v5.6.1 baseline. +- **Phase 2 — Compact UI, Reload-Free Refresh & Production Gate:** implemented in the v5.7.0 source baseline and corrected in the v5.7.1 candidate with server-rendered fallback, 30-second authenticated AJAX polling, manual refresh, in-place KPI/chart/activity updates, overlap protection and stale/auth UX. v5.7.1 additionally verifies stale/auth/transport/render lifecycle correctness; remote CI and live production smoke remain release gates. Roadmap items are proposals and must be implemented through reviewed, backward-compatible changes. diff --git a/admin/assets/css/admin-ui.css b/admin/assets/css/admin-ui.css index 04fa54c..1355b0b 100644 --- a/admin/assets/css/admin-ui.css +++ b/admin/assets/css/admin-ui.css @@ -1,2 +1,80 @@ /* Compatibility entrypoint. All Licora admin visuals are sourced from the v5.4.0 component system. */ @import url('./licora/licora-ui.css'); + +/* Licora v5.7.0 Dashboard Phase 2 — scoped compact operations view. */ +.dashboard-page .dashboard-header { align-items: center; } +.dashboard-page .dashboard-refresh-group { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } +.dashboard-page .dashboard-refresh-meta { display: grid; gap: 1px; text-align: right; color: var(--text-secondary); font-size: 10px; line-height: 1.25; } +.dashboard-page .dashboard-refresh-meta strong { color: var(--text-primary); font-size: 11px; font-weight: var(--font-weight-bold); } +.dashboard-page [data-dashboard-refresh].is-loading i { animation: dashboard-spin .8s linear infinite; } +.dashboard-page .dashboard-state { display: flex; align-items: center; gap: 7px; min-height: 34px; margin: -2px auto 10px; padding: 7px 10px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--component-feedback-bg); color: var(--text-secondary); font-size: 11px; } +.dashboard-page .dashboard-state[hidden] { display: none!important; } +.dashboard-page .dashboard-state.is-warning { border-color: var(--state-warning-border); background: var(--state-warning-bg); color: var(--state-warning-text); } +.dashboard-page .dashboard-state.is-danger { border-color: var(--state-danger-border); background: var(--state-danger-bg); color: var(--state-danger-text); } +.dashboard-page .dashboard-state a { margin-left: auto; color: inherit; font-weight: var(--font-weight-bold); } + +.dashboard-page .dashboard-health-strip { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); gap: 1px; margin-bottom: 12px; overflow: hidden; border: 1px solid var(--border-card); border-radius: var(--radius-lg); background: var(--border-inner); } +.dashboard-page .dashboard-health-item { min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px 10px; background: var(--bg-surface-card); color: var(--text-secondary); font-size: 10.5px; } +.dashboard-page .dashboard-health-item strong { margin-left: auto; overflow: hidden; color: var(--text-primary); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.dashboard-page .dashboard-health-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 999px; background: var(--text-muted); } +.dashboard-page .dashboard-health-item.is-ok .dashboard-health-dot { background: var(--status-success); } +.dashboard-page .dashboard-health-item.is-warning .dashboard-health-dot { background: var(--status-warning); } +.dashboard-page .dashboard-health-item.is-danger .dashboard-health-dot { background: var(--status-danger); } + +.dashboard-page .dashboard-kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; margin-bottom: 12px; } +.dashboard-page .dashboard-kpi-card { min-width: 0; display: grid; grid-template-columns: 34px minmax(0,1fr); gap: 10px; align-items: start; padding: 12px; border: 1px solid var(--border-card); border-radius: var(--radius-lg); background: var(--bg-surface-card); } +.dashboard-page .dashboard-kpi-icon { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--component-active-border); border-radius: var(--radius-md); background: var(--component-active-bg); color: var(--licora-primary); font-size: 15px; } +.dashboard-page .dashboard-kpi-copy { min-width: 0; display: grid; gap: 2px; } +.dashboard-page .dashboard-kpi-label { color: var(--text-secondary); font-size: 10.5px; font-weight: var(--font-weight-medium); } +.dashboard-page .dashboard-kpi-value { color: var(--text-primary); font-size: 24px; line-height: 1.05; letter-spacing: -.03em; font-weight: var(--font-weight-bold); } +.dashboard-page .dashboard-kpi-meta { overflow: hidden; color: var(--text-muted); font-size: 9.75px; line-height: 1.35; text-overflow: ellipsis; } + +.dashboard-page .dashboard-chart-grid { display: grid; grid-template-columns: minmax(0,1.35fr) minmax(320px,.85fr); gap: 10px; margin-bottom: 10px; } +.dashboard-page .dashboard-operations-grid { display: grid; grid-template-columns: minmax(0,1.65fr) minmax(260px,.65fr); gap: 10px; margin-bottom: 10px; } +.dashboard-page .dashboard-panel { min-width: 0; margin: 0; } +.dashboard-page .dashboard-panel .card-header > div { min-width: 0; } +.dashboard-page .dashboard-panel .card-header small { display: block; margin-top: 2px; color: var(--text-muted); font-size: 9.5px; font-weight: var(--font-weight-normal); } +.dashboard-page .dashboard-chart-body { position: relative; min-height: 250px; } +.dashboard-page .dashboard-chart-body canvas { width: 100%!important; height: 226px!important; } + +.dashboard-page .dashboard-quick-actions { display: grid; gap: 4px; } +.dashboard-page .dashboard-action-link { min-height: 45px; display: grid; grid-template-columns: 28px minmax(0,1fr) 14px; gap: 8px; align-items: center; padding: 7px 8px; border: 1px solid transparent; border-radius: var(--radius-md); color: var(--text-content); text-decoration: none; } +.dashboard-page .dashboard-action-link:hover { border-color: var(--border-subtle); background: var(--component-hover-bg); color: var(--text-primary); text-decoration: none; } +.dashboard-page .dashboard-action-link > i:first-child { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-surface-soft); color: var(--licora-primary); } +.dashboard-page .dashboard-action-link > i:last-child { color: var(--text-muted); font-size: 10px; } +.dashboard-page .dashboard-action-link span { min-width: 0; display: grid; gap: 1px; } +.dashboard-page .dashboard-action-link strong { overflow: hidden; color: var(--text-primary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.dashboard-page .dashboard-action-link small { overflow: hidden; color: var(--text-muted); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } + +.dashboard-page .dashboard-activity-table th, +.dashboard-page .dashboard-top-licenses-table th { white-space: nowrap; } +.dashboard-page .dashboard-activity-table td { vertical-align: middle; } +.dashboard-page .dashboard-source-badge { display: inline-flex; min-height: 20px; align-items: center; padding: 2px 6px; border: 1px solid var(--border-subtle); border-radius: 999px; background: var(--bg-surface-soft); color: var(--text-secondary); font-size: 9.5px; font-weight: var(--font-weight-medium); white-space: nowrap; } +.dashboard-page .dashboard-empty { padding: 26px 12px; color: var(--text-muted); text-align: center; font-size: 11px; } +.dashboard-page .dashboard-top-licenses-panel { margin-bottom: 0; } + +@keyframes dashboard-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .dashboard-page [data-dashboard-refresh].is-loading i { animation: none; } +} +@media (max-width: 1099.98px) { + .dashboard-page .dashboard-health-strip { grid-template-columns: repeat(3,minmax(0,1fr)); } + .dashboard-page .dashboard-kpi-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .dashboard-page .dashboard-chart-grid { grid-template-columns: 1fr; } + .dashboard-page .dashboard-operations-grid { grid-template-columns: minmax(0,1.35fr) minmax(240px,.65fr); } +} +@media (max-width: 767.98px) { + .dashboard-page .dashboard-refresh-group { width: 100%; justify-content: space-between; } + .dashboard-page .dashboard-refresh-meta { text-align: left; } + .dashboard-page .dashboard-health-strip { grid-template-columns: 1fr 1fr; } + .dashboard-page .dashboard-operations-grid { grid-template-columns: 1fr; } + .dashboard-page .dashboard-activity-panel { order: 2; } + .dashboard-page .dashboard-actions-panel { order: 1; } +} +@media (max-width: 479.98px) { + .dashboard-page .dashboard-health-strip, + .dashboard-page .dashboard-kpi-grid { grid-template-columns: 1fr; } + .dashboard-page .dashboard-kpi-value { font-size: 22px; } + .dashboard-page .dashboard-chart-body { min-height: 220px; } + .dashboard-page .dashboard-chart-body canvas { height: 200px!important; } +} diff --git a/admin/assets/js/dashboard.js b/admin/assets/js/dashboard.js new file mode 100644 index 0000000..4220a43 --- /dev/null +++ b/admin/assets/js/dashboard.js @@ -0,0 +1,439 @@ +(function (root, factory) { + var api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.LicoraDashboard = api; +})(typeof window !== 'undefined' ? window : globalThis, function () { + 'use strict'; + + var DEFAULT_POLL_MS = 30000; + + function asInt(value) { + var parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; + } + + function text(value, fallback) { + if (value === null || value === undefined || value === '') return fallback || '—'; + return String(value); + } + + function formatTimestamp(value) { + if (!value) return '—'; + var date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } + + function formatActivityTime(value) { + if (!value) return '—'; + var date = new Date(value.replace(' ', 'T')); + if (Number.isNaN(date.getTime())) return text(value, '—'); + return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + } + + function indexSeries(series) { + var indexed = Object.create(null); + (Array.isArray(series) ? series : []).forEach(function (point) { + if (!point || !point.date) return; + indexed[String(point.date)] = asInt(point.count); + }); + return indexed; + } + + function alignSeries(left, right) { + var labels = Object.create(null); + (Array.isArray(left) ? left : []).concat(Array.isArray(right) ? right : []).forEach(function (point) { + if (point && point.date) labels[String(point.date)] = true; + }); + var ordered = Object.keys(labels).sort(); + var leftIndex = indexSeries(left); + var rightIndex = indexSeries(right); + return { + labels: ordered, + left: ordered.map(function (label) { return leftIndex[label] || 0; }), + right: ordered.map(function (label) { return rightIndex[label] || 0; }) + }; + } + + function combineRecentActivity(recent) { + var result = []; + var v1 = recent && Array.isArray(recent.v1_tracked) ? recent.v1_tracked : []; + var v2 = recent && Array.isArray(recent.v2_tracked) ? recent.v2_tracked : []; + v1.forEach(function (call) { + var code = asInt(call && call.response_code); + result.push({ + source: 'API v1', + action: text(call && call.endpoint, 'verify'), + context: call && call.license_key ? String(call.license_key).slice(0, 12) + '…' : 'No license', + result: String(code), + tone: code === 200 ? 'success' : (code >= 400 && code < 500 ? 'warning' : 'danger'), + created_at: text(call && call.created_at, '') + }); + }); + v2.forEach(function (event) { + var parts = []; + if (event && event.app_id) parts.push(String(event.app_id)); + if (event && event.license_id !== null && event.license_id !== undefined) parts.push('License #' + asInt(event.license_id)); + result.push({ + source: 'API v2', + action: text(event && event.event_type, 'audit_event'), + context: parts.length ? parts.join(' · ') : 'Audit event', + result: 'Recorded', + tone: 'primary', + created_at: text(event && event.created_at, '') + }); + }); + result.sort(function (a, b) { + var at = Date.parse(String(a.created_at).replace(' ', 'T')) || 0; + var bt = Date.parse(String(b.created_at).replace(' ', 'T')) || 0; + return bt - at; + }); + return result.slice(0, 12); + } + + function validatePayload(payload) { + if (!payload || payload.success !== true || !payload.data || typeof payload.data !== 'object') { + throw new Error('Dashboard response contract is invalid.'); + } + ['licenses', 'devices', 'api_activity', 'recent_activity', 'expiration', 'health'].forEach(function (key) { + if (!payload.data[key] || typeof payload.data[key] !== 'object') { + throw new Error('Dashboard response is missing ' + key + '.'); + } + }); + return payload; + } + + function createController(options) { + options = options || {}; + var request = options.request; + var view = options.view || {}; + var pollMs = asInt(options.pollMs) || DEFAULT_POLL_MS; + var setTimer = options.setTimer || function (fn, ms) { return setInterval(fn, ms); }; + var clearTimer = options.clearTimer || function (id) { clearInterval(id); }; + var inFlight = false; + var timer = null; + var stopped = false; + var lastSuccessAt = null; + + function notify(name) { + if (typeof view[name] === 'function') { + var args = Array.prototype.slice.call(arguments, 1); + view[name].apply(view, args); + } + } + + function refresh(reason) { + if (stopped || inFlight) return Promise.resolve({ skipped: true }); + if (typeof request !== 'function') return Promise.reject(new Error('Dashboard request transport is unavailable.')); + inFlight = true; + notify('setLoading', true, reason || 'poll'); + return Promise.resolve().then(function () { return request(); }).then(function (response) { + if (response && response.status === 401) { + stopped = true; + if (timer !== null) { clearTimer(timer); timer = null; } + notify('showAuthRequired'); + return { authRequired: true }; + } + if (!response || response.ok !== true) { + var error = new Error('Dashboard refresh failed.'); + error.status = response ? response.status : 0; + throw error; + } + return response.json(); + }).then(function (payload) { + if (payload && payload.authRequired) return payload; + validatePayload(payload); + var completedAt = payload.generated_at || new Date().toISOString(); + notify('render', payload); + lastSuccessAt = completedAt; + notify('showFresh', lastSuccessAt); + return payload; + }).catch(function (error) { + notify('showStale', lastSuccessAt, error); + return { error: error }; + }).finally(function () { + inFlight = false; + notify('setLoading', false, reason || 'poll'); + }); + } + + function start() { + if (stopped || timer !== null) return; + timer = setTimer(function () { refresh('poll'); }, pollMs); + } + + function stop() { + stopped = true; + if (timer !== null) { clearTimer(timer); timer = null; } + } + + function seed(payload) { + validatePayload(payload); + lastSuccessAt = payload.generated_at || null; + notify('render', payload); + notify('showFresh', lastSuccessAt); + } + + return { + refresh: refresh, + start: start, + stop: stop, + seed: seed, + isInFlight: function () { return inFlight; }, + isStopped: function () { return stopped; }, + getLastSuccessAt: function () { return lastSuccessAt; } + }; + } + + function createElement(documentRef, tag, className, value) { + var el = documentRef.createElement(tag); + if (className) el.className = className; + if (value !== undefined) el.textContent = String(value); + return el; + } + + function createDomView(documentRef, chartFactory) { + var apiChart = null; + var expirationChart = null; + var refreshButton = documentRef.querySelector('[data-dashboard-refresh]'); + var refreshText = documentRef.querySelector('[data-dashboard-refresh-text]'); + var updatedAt = documentRef.querySelector('[data-dashboard-updated-at]'); + var state = documentRef.querySelector('[data-dashboard-state]'); + var stateText = documentRef.querySelector('[data-dashboard-state-text]'); + var signin = documentRef.querySelector('[data-dashboard-signin]'); + var authLocked = false; + + function cssVar(name, fallback) { + if (!documentRef.defaultView || !documentRef.defaultView.getComputedStyle) return fallback; + var value = documentRef.defaultView.getComputedStyle(documentRef.documentElement).getPropertyValue(name).trim(); + return value || fallback; + } + + function setState(message, tone, showSignin) { + if (!state || !stateText) return; + state.hidden = !message; + state.classList.remove('is-warning', 'is-danger'); + if (tone) state.classList.add('is-' + tone); + stateText.textContent = message || ''; + if (signin) signin.hidden = !showSignin; + } + + function setText(selector, value) { + var node = documentRef.querySelector(selector); + if (node) node.textContent = String(value); + } + + function updateHealth(health) { + var v2Ready = !!(health.api_v2 && health.api_v2.schema_ready && health.api_v2.key_pair_ready); + var items = { + database: { value: text(health.database && health.database.label), tone: health.database && health.database.ok ? 'ok' : 'danger' }, + api_v2: { value: v2Ready ? 'Ready' : 'Needs setup', tone: v2Ready ? 'ok' : 'warning' }, + cron_scripts: { value: health.cron_scripts && health.cron_scripts.available ? 'Available' : 'Missing', tone: health.cron_scripts && health.cron_scripts.available ? 'ok' : 'danger' }, + php: { value: text(health.php && health.php.version), tone: health.php && health.php.ok ? 'ok' : 'danger' }, + environment: { value: text(health.environment && health.environment.value), tone: 'neutral' } + }; + Object.keys(items).forEach(function (key) { + var item = documentRef.querySelector('[data-dashboard-health="' + key + '"]'); + var value = documentRef.querySelector('[data-dashboard-health-value="' + key + '"]'); + if (value) value.textContent = key === 'environment' ? items[key].value.charAt(0).toUpperCase() + items[key].value.slice(1) : items[key].value; + if (item) { + item.classList.remove('is-ok', 'is-warning', 'is-danger', 'is-neutral'); + item.classList.add('is-' + items[key].tone); + } + }); + } + + function updateCharts(data) { + if (typeof chartFactory !== 'function') return; + var api = alignSeries(data.api_activity.v1_tracked.last_14_days, data.api_activity.v2_tracked.last_14_days); + var expiration = alignSeries(data.expiration.expired_last_30_days, data.expiration.expiring_next_30_days); + var primary = cssVar('--licora-primary', '#2563eb'); + var secondary = cssVar('--licora-secondary', '#7c3aed'); + var danger = cssVar('--status-danger', '#c9363e'); + var warning = cssVar('--status-warning', '#b76d00'); + var muted = cssVar('--text-muted', '#7b8798'); + var border = cssVar('--border-inner', '#e7ebf1'); + var chartOptions = { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: 'index', intersect: false }, + plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, color: muted } } }, + scales: { x: { grid: { display: false }, ticks: { color: muted, maxTicksLimit: 7 } }, y: { beginAtZero: true, grid: { color: border }, ticks: { color: muted, precision: 0 } } } + }; + if (!apiChart) { + var apiCanvas = documentRef.getElementById('dailyApiChart'); + if (apiCanvas) apiChart = chartFactory(apiCanvas, { type: 'line', data: { labels: api.labels, datasets: [ + { label: 'API v1 Verify', data: api.left, borderColor: primary, backgroundColor: primary, tension: .25, pointRadius: 2 }, + { label: 'API v2 Audit Events', data: api.right, borderColor: secondary, backgroundColor: secondary, tension: .25, pointRadius: 2 } + ] }, options: chartOptions }); + } else { + apiChart.data.labels = api.labels; + apiChart.data.datasets[0].data = api.left; + apiChart.data.datasets[1].data = api.right; + apiChart.update('none'); + } + if (!expirationChart) { + var expirationCanvas = documentRef.getElementById('expiredTrendChart'); + if (expirationCanvas) expirationChart = chartFactory(expirationCanvas, { type: 'bar', data: { labels: expiration.labels, datasets: [ + { label: 'Expired — Last 30 Days', data: expiration.left, backgroundColor: danger }, + { label: 'Expiring — Next 30 Days', data: expiration.right, backgroundColor: warning } + ] }, options: chartOptions }); + } else { + expirationChart.data.labels = expiration.labels; + expirationChart.data.datasets[0].data = expiration.left; + expirationChart.data.datasets[1].data = expiration.right; + expirationChart.update('none'); + } + } + + function updateRecentActivity(recent) { + var tbody = documentRef.querySelector('[data-dashboard-recent-activity]'); + if (!tbody) return; + while (tbody.firstChild) tbody.removeChild(tbody.firstChild); + var rows = combineRecentActivity(recent); + if (!rows.length) { + var emptyRow = createElement(documentRef, 'tr'); + var emptyCell = createElement(documentRef, 'td'); + emptyCell.colSpan = 5; + emptyCell.appendChild(createElement(documentRef, 'div', 'dashboard-empty', 'No tracked activity yet.')); + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + return; + } + rows.forEach(function (item) { + var tr = createElement(documentRef, 'tr'); + tr.appendChild(createElement(documentRef, 'td', '', formatActivityTime(item.created_at))); + var sourceCell = createElement(documentRef, 'td'); + sourceCell.appendChild(createElement(documentRef, 'span', 'dashboard-source-badge', item.source)); + tr.appendChild(sourceCell); + var actionCell = createElement(documentRef, 'td'); + actionCell.appendChild(createElement(documentRef, 'code', '', item.action)); + tr.appendChild(actionCell); + tr.appendChild(createElement(documentRef, 'td', '', item.context)); + var resultCell = createElement(documentRef, 'td'); + resultCell.appendChild(createElement(documentRef, 'span', 'badge bg-' + item.tone, item.result)); + tr.appendChild(resultCell); + tbody.appendChild(tr); + }); + } + + function updateTopLicenses(apiActivity) { + var tbody = documentRef.querySelector('[data-dashboard-top-licenses]'); + if (!tbody) return; + while (tbody.firstChild) tbody.removeChild(tbody.firstChild); + var rows = apiActivity && apiActivity.v1_tracked && Array.isArray(apiActivity.v1_tracked.top_licenses) ? apiActivity.v1_tracked.top_licenses : []; + if (!rows.length) { + var emptyRow = createElement(documentRef, 'tr'); + var emptyCell = createElement(documentRef, 'td'); + emptyCell.colSpan = 2; + emptyCell.appendChild(createElement(documentRef, 'div', 'dashboard-empty', 'No tracked API v1 license activity yet.')); + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + return; + } + rows.forEach(function (license) { + var tr = createElement(documentRef, 'tr'); + var licenseCell = createElement(documentRef, 'td'); + licenseCell.appendChild(createElement(documentRef, 'code', '', license && license.license_key ? String(license.license_key).slice(0, 18) : 'Unknown')); + tr.appendChild(licenseCell); + var countCell = createElement(documentRef, 'td', 'text-end'); + countCell.appendChild(createElement(documentRef, 'span', 'badge bg-primary', asInt(license && license.count))); + tr.appendChild(countCell); + tbody.appendChild(tr); + }); + } + + return { + render: function (payload) { + var data = payload.data; + setText('[data-dashboard-kpi="total_licenses"]', asInt(data.licenses.total)); + setText('[data-dashboard-kpi="active_licenses"]', asInt(data.licenses.active)); + setText('[data-dashboard-kpi="recent_devices"]', asInt(data.devices.recently_seen)); + setText('[data-dashboard-kpi="expiring_soon"]', asInt(data.licenses.expiring_soon)); + setText('[data-dashboard-kpi-meta="expired_licenses"]', asInt(data.licenses.expired)); + setText('[data-dashboard-kpi-meta="suspended_licenses"]', asInt(data.licenses.suspended)); + setText('[data-dashboard-kpi-meta="active_devices"]', asInt(data.devices.active_flagged)); + setText('[data-dashboard-kpi-meta="total_devices"]', asInt(data.devices.total_records)); + updateHealth(data.health); + updateCharts(data); + updateRecentActivity(data.recent_activity); + updateTopLicenses(data.api_activity); + }, + setLoading: function (loading) { + if (refreshButton) { + refreshButton.disabled = authLocked || !!loading; + refreshButton.classList.toggle('is-loading', !!loading); + } + if (loading && refreshText) refreshText.textContent = 'Refreshing…'; + }, + showFresh: function (timestamp) { + authLocked = false; + if (updatedAt) updatedAt.textContent = formatTimestamp(timestamp); + setState('', '', false); + if (refreshButton) refreshButton.disabled = false; + if (refreshText) refreshText.textContent = 'Refresh'; + }, + showStale: function (lastSuccessAt) { + var suffix = lastSuccessAt ? ' Last successful update: ' + formatTimestamp(lastSuccessAt) + '.' : ''; + setState('Data may be stale.' + suffix, 'warning', false); + if (refreshText) refreshText.textContent = 'Retry'; + }, + showAuthRequired: function () { + authLocked = true; + setState('Session expired. Sign in again to continue dashboard refresh.', 'danger', true); + if (refreshText) refreshText.textContent = 'Refresh paused'; + if (refreshButton) refreshButton.disabled = true; + } + }; + } + + function initBrowser(documentRef, windowRef) { + var root = documentRef.getElementById('licora-dashboard'); + if (!root) return null; + var endpoint = root.getAttribute('data-dashboard-endpoint') || 'ajax/dashboard-data.php'; + var pollMs = asInt(root.getAttribute('data-dashboard-poll-ms')) || DEFAULT_POLL_MS; + var chartFactory = windowRef.Chart ? function (canvas, config) { return new windowRef.Chart(canvas, config); } : null; + var view = createDomView(documentRef, chartFactory); + var controller = createController({ + pollMs: pollMs, + view: view, + request: function () { + return windowRef.fetch(endpoint, { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store', + headers: { 'Accept': 'application/json' } + }); + } + }); + var initialNode = documentRef.getElementById('dashboard-initial-data'); + if (initialNode) { + try { controller.seed(JSON.parse(initialNode.textContent || '{}')); } + catch (error) { view.showStale(null, error); } + } + var refresh = documentRef.querySelector('[data-dashboard-refresh]'); + if (refresh) refresh.addEventListener('click', function () { controller.refresh('manual'); }); + controller.start(); + return controller; + } + + if (typeof document !== 'undefined' && typeof window !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { initBrowser(document, window); }, { once: true }); + } else { + initBrowser(document, window); + } + } + + return { + DEFAULT_POLL_MS: DEFAULT_POLL_MS, + alignSeries: alignSeries, + combineRecentActivity: combineRecentActivity, + validatePayload: validatePayload, + createController: createController, + createDomView: createDomView, + initBrowser: initBrowser, + formatTimestamp: formatTimestamp + }; +}); diff --git a/admin/index.php b/admin/index.php index 29d37b0..cdcec33 100644 --- a/admin/index.php +++ b/admin/index.php @@ -7,7 +7,7 @@ $auth = new Auth(); if (!$auth->isAdminLoggedIn()) { - header("Location: login.php"); + header('Location: login.php'); exit(); } @@ -15,46 +15,58 @@ $dashboardSnapshot = $dashboard->snapshot(); $licenseStats = $dashboardSnapshot['licenses']; $deviceStats = $dashboardSnapshot['devices']; -$apiStats = $dashboardSnapshot['api_keys']; $apiActivity = $dashboardSnapshot['api_activity']; $expiration = $dashboardSnapshot['expiration']; $health = $dashboardSnapshot['health']; - -$chartV1 = $apiActivity['v1_tracked']['last_14_days']; -$chartV2 = $apiActivity['v2_tracked']['last_14_days']; $topLicenses = $apiActivity['v1_tracked']['top_licenses']; -$recentCalls = $apiActivity['v1_tracked']['recent_calls']; -$apiDates = []; -foreach (array_merge($chartV1, $chartV2) as $point) { - if (!empty($point['date'])) { - $apiDates[(string)$point['date']] = true; - } +$recentActivity = []; +foreach ($dashboardSnapshot['recent_activity']['v1_tracked'] as $call) { + $recentActivity[] = [ + 'timestamp' => strtotime((string)($call['created_at'] ?? '')) ?: 0, + 'time' => (string)($call['created_at'] ?? ''), + 'source' => 'API v1', + 'action' => (string)($call['endpoint'] ?? 'verify'), + 'context' => !empty($call['license_key']) ? substr((string)$call['license_key'], 0, 12) . '…' : 'No license', + 'result' => (string)((int)($call['response_code'] ?? 0)), + 'result_class' => (int)($call['response_code'] ?? 0) === 200 ? 'success' : (((int)($call['response_code'] ?? 0) >= 400 && (int)($call['response_code'] ?? 0) < 500) ? 'warning' : 'danger'), + ]; } -$apiLabels = array_keys($apiDates); -sort($apiLabels); -$seriesByDate = static function (array $series, array $labels): array { - $indexed = []; - foreach ($series as $point) { - $indexed[(string)($point['date'] ?? '')] = (int)($point['count'] ?? 0); +foreach ($dashboardSnapshot['recent_activity']['v2_tracked'] as $event) { + $contextParts = []; + if (!empty($event['app_id'])) { + $contextParts[] = (string)$event['app_id']; } - return array_map(static fn(string $date): int => (int)($indexed[$date] ?? 0), $labels); -}; -$apiV1Data = $seriesByDate($chartV1, $apiLabels); -$apiV2Data = $seriesByDate($chartV2, $apiLabels); - -$expiredSeries = $expiration['expired_last_30_days']; -$expiringSeries = $expiration['expiring_next_30_days']; -$expirationDates = []; -foreach (array_merge($expiredSeries, $expiringSeries) as $point) { - if (!empty($point['date'])) { - $expirationDates[(string)$point['date']] = true; + if ($event['license_id'] !== null) { + $contextParts[] = 'License #' . (int)$event['license_id']; } + $recentActivity[] = [ + 'timestamp' => strtotime((string)($event['created_at'] ?? '')) ?: 0, + 'time' => (string)($event['created_at'] ?? ''), + 'source' => 'API v2', + 'action' => (string)($event['event_type'] ?? 'audit_event'), + 'context' => $contextParts !== [] ? implode(' · ', $contextParts) : 'Audit event', + 'result' => 'Recorded', + 'result_class' => 'primary', + ]; } -$expirationLabels = array_keys($expirationDates); -sort($expirationLabels); -$expiredData = $seriesByDate($expiredSeries, $expirationLabels); -$expiringData = $seriesByDate($expiringSeries, $expirationLabels); +usort($recentActivity, static fn(array $a, array $b): int => $b['timestamp'] <=> $a['timestamp']); +$recentActivity = array_slice($recentActivity, 0, 12); + +$v2Ready = !empty($health['api_v2']['schema_ready']) && !empty($health['api_v2']['key_pair_ready']); +$initialPayload = [ + 'success' => true, + 'generated_at' => $dashboardSnapshot['generated_at'], + 'data' => [ + 'licenses' => $dashboardSnapshot['licenses'], + 'devices' => $dashboardSnapshot['devices'], + 'api_keys' => $dashboardSnapshot['api_keys'], + 'api_activity' => $dashboardSnapshot['api_activity'], + 'recent_activity' => $dashboardSnapshot['recent_activity'], + 'expiration' => $dashboardSnapshot['expiration'], + 'health' => $dashboardSnapshot['health'], + ], +]; ?> @@ -67,265 +79,212 @@ -
+ -License system overview