PSI Portal
The PSI Portal is the central landing page and application launcher for all PSI web applications.
Quick Links
| Resource | URL |
|---|---|
| Production | https://portal.progressivesurface.com |
| Repository | ProgressiveSurface/psi-portal |
| Azure App | psi-portal.azurewebsites.net |
Features
Application Cards
Visual cards for each PSI web application with:
- App icon and name
- Description
- Status indicator (Live, Beta, Coming Soon)
- Category badge (Engineering, Quality, Data & Integration, Administration)
- Click to open in new tab
Quick Stats Dashboard
- Count of live web applications
- ProApps desktop application count (35+)
- REST API count
- System status indicator
Desktop Apps Section
Information about PSI ProApps with link to documentation for users looking for desktop applications.
Technology Stack
| Component | Technology |
|---|---|
| Framework | React 19 |
| Language | TypeScript |
| Build Tool | Vite |
| Styling | TailwindCSS v4 |
| Icons | Lucide React |
| API Runtime | Node.js / Express |
Production Configuration
| Setting | Value |
|---|---|
| Azure App Service | psi-portal |
| Resource Group | PS-WEBAPPS |
| App Service Plan | asp-erp-migration-tool |
| Runtime | Node 20 LTS (Linux) |
| Custom Domain | portal.progressivesurface.com |
| Private Endpoint IP | 10.160.0.6 |
| Entra App ID | 7f929c7f-2483-4206-93b6-11225e07ca85 |
Auth Profile
psi-portal is an approved client/app-layer auth application under the PSI web app compliance standard.
- Platform auth: disabled (
AllowAnonymous) so the app keeps the approvedpsi-portalauth profile instead of becoming an EasyAuth-enforced exception. - SPA auth: Entra ID via MSAL in the React client using redirect-based auth only.
- Auth cache: MSAL
localStorageso the portal behaves more like PSI’s pass-through internal apps and does not force frequent re-auth on user-specific workstations. - Client bootstrap: redirect result handled first, active account restored from cache/events, stale cached auth cleared before the next redirect.
- Calibration API auth: server-side token validation in the Node/Express layer using the authenticated app-layer bearer token.
- Route expectation:
/returns200- calibration APIs return
401when called without the required authenticated app-layer token
Quality Modules — Visibility
As of 2026-05-20, the labels sub-apps — Calibration Labels (/quality/calibration-labels), ZPL Lab (/quality/zpl-lab, reached via Calibration Labels), and Label Designer (/quality/labels) — are visible to all authenticated portal users. They were previously alpha-gated to a single UPN.
Calibration Portal (/quality/calibration) stays alpha-gated: U:\CALIBRATION\Calibration Log.xlsx remains the authoritative system of record for calibration data, and the portal’s CalibrationPortal Azure SQL DB is a migration target — not yet the live calibration system. Ungating the portal would invite confusion about where the canonical records live. It is hidden from non-allowlisted users until a cutover decision lands.
Two caveats still apply to the visible labels sub-apps:
- Calibration Labels, ZPL Lab, and Label Designer all print to the real shop-floor Zebra ZD621 (
10.150.122.37:9100). Test prints consume real label stock. - They share the
CalibrationPortalAzure SQL DB for label templates and images, even though the calibration record UI is not yet exposed.
CapEx Portal (/finance/capex) also remains alpha-gated and is not in business use; its DB is on Basic and retirement is pending.
Calibration Module
The portal now includes a SQL-backed calibration module for operations and label printing.
- Runtime data source: Azure SQL database
CalibrationPortal - Database access: App Service managed identity
- Legacy workbook: migration/bootstrap only, not used at runtime
- Protected features:
- calibration dashboard/search/detail
- intake and update flows
- label printing to the Zebra calibration printer
Label Preview — Labelary ZPL Rendering
The calibration label page shows a live WYSIWYG preview rendered by the Labelary ZPL Web Service, a free third-party API that converts ZPL commands into PNG images.
| Setting | Value |
|---|---|
| API endpoint | https://api.labelary.com/v1/printers/12dpmm/labels/2x1.25/0/ |
| DPI parameter | 12dpmm (≈ 304 DPI — closest Labelary supports to our 300 DPI ZD621) |
| Label size | 2x1.25 (width × height in inches) |
| Request method | POST with raw ZPL as the body, Accept: image/png |
| Timeout | 8 seconds (AbortSignal.timeout) |
How it works:
- Browser sends form data to
POST /api/calibration-labels/preview(Express server) - Server builds ZPL from the form fields using
buildCalibrationLabelZpl() - Server POSTs the raw ZPL string to Labelary and returns the PNG to the browser
- Browser displays the PNG as the live preview
Important: The ZPL body must be sent as a raw string — not wrapped in URLSearchParams or form-encoded data= parameters. Labelary returns 404 for form-encoded payloads.
Preview is non-critical. If Labelary is unreachable or returns an error, the preview simply doesn’t render. The print path (direct TCP to the Zebra) is completely independent and still works.
Label ZPL Layout
The label is 600 × 375 dots (^PW600 ^LL375) at 300 DPI = 2” × 1.25”. All text uses ^A0I (180° rotation) because the label feeds inverted through the printer. The preprinted color bands (PSI logo at top, footer at bottom) occupy roughly the outer 75 dots on each edge; variable text is centered in the ~225-dot white band between them.
| Row | Field | Justification |
|---|---|---|
| 1 | S/N | Center |
| 2 | Cal date / Exp date | Left / Right |
| 3 | Calibration ID | Center |
| 4 | Calibrated By | Center |
Font size is 42pt for all rows. Darkness is set to ^MD15 for the variable text, reset to ^MD0 at the end to avoid affecting subsequent labels.
Label Designer Module
Status: Beta. Open to all authenticated portal users since 2026-05-20.
A general-purpose, browser-based label designer (“Bartender-lite”) at /quality/labels. Coexists with the existing CalibrationLabels form and ZplLab raw editor — eventually intended to subsume both.
Features
- Shared template library — anyone can read or edit any template. Templates are stored in the existing
CalibrationPortalAzure SQL DB (two new tables:label_templates,label_images). - WYSIWYG canvas — drag-to-move, click-to-select. Resize and font/content edits via the property panel.
- Element types — Text, QR Code, Code 128 barcode, Line, Box, and uploaded Image (PNG re-encoded to ZPL
^GFA). - Variables — author placeholders like
{serial}and fill them in at print time. - Live preview — Labelary-rendered PNG, debounced.
- Three view modes — Design (canvas), ZPL (read-only generated source), Preview (PNG).
- Raw mode — power-user escape hatch that swaps the canvas for a free-form ZPL textarea. One-way: switching back to design mode discards the raw ZPL.
- Single printer / selectable size — prints to the shared Zebra ZD621 at
10.150.122.37:9100(same printer the calibration label uses). Label size is picked per template from a fixed list (2×1.25, 4×6, 3×1, 1×2, 4×2, 4×4) at 300 DPI.
Routes
| Path | Purpose |
|---|---|
/quality/labels | Template gallery (list, new, copy, archive) |
/quality/labels/new | Editor — new template |
/quality/labels/:id/edit | Editor — existing template |
API surface (/api/labels/*)
All endpoints (except /health) require an Entra ID bearer token validated against the portal’s app ID 7f929c7f-….
GET /api/labels/health
GET /api/labels/templates list (?includeArchived=true to show archived)
GET /api/labels/templates/:id
POST /api/labels/templates create { name, widthInches, heightInches, mode, designJson|rawZpl }
PUT /api/labels/templates/:id update
DELETE /api/labels/templates/:id soft-archive
POST /api/labels/templates/:id/copy
POST /api/labels/render/preview { templateId | design | rawZpl, variables } → PNG via Labelary
POST /api/labels/render/zpl same body → text/plain ZPL
POST /api/labels/render/print same body → sends ZPL to the Zebra ZD621
GET /api/labels/images
GET /api/labels/images/:id/png
POST /api/labels/images (multer) upload, sharp-encode to ^GFA, persist
DELETE /api/labels/images/:id
Configuration
Environment variables (defaults in .env.example):
| Variable | Default | Purpose |
|---|---|---|
LABELS_DB_AUTH | managed-identity | mssql auth mode |
LABELS_DB_SERVER | procserv-proddata.database.windows.net | Azure SQL host |
LABELS_DB_DATABASE | CalibrationPortal | reuses the calibration DB; labels and calibration share a tier |
LABELS_AUTH_CLIENT_ID | 7f929c7f-… | portal Entra app |
LABELS_AUTH_TENANT_ID | a83ae943-… | PSI tenant |
LABELS_API_ALLOW_LOCAL_BYPASS | false | dev-only auth bypass |
Schema apply
The schema (server/labels/schema.sql) is applied automatically on server startup via ensureLabelsSchema() in server/labels/database.js. The DDL is wrapped in IF OBJECT_ID(...) IS NULL / IF NOT EXISTS so re-running on every boot is safe. Failure is logged and non-fatal; the next call retries implicitly.
To apply manually (e.g. against a fresh database from a workstation with SQL access):
npm run labels:db:apply-schema # apply to LABELS_DB_DATABASE
npm run labels:db:apply-schema -- --dry-run # print batch count and exitWhat is intentionally not built (yet)
- CSV-batch printing (variables + N labels).
- Multi-printer registry (today there is exactly one Zebra).
- Round-tripping raw ZPL back into design mode.
- Image trimming/cropping inside the editor.
Customer Data Reconciliation Module
Status: Live at
/finance/reconciliation, open to all authenticated portal users. Finance / Accounting (AR) / Sales-ops tool.
Productizes the reusable company_analysis.py engine (in the d365-ce-migration workspace) so non-developers can review and clean up AFTEC customer data ahead of the Business Central + Dynamics 365 CE migration. Full evaluation: d365-ce-migration/docs/Customer-Data-Reconciliation-App-Eval.md.
The review screen is account-first master/detail, mirroring the real AFTEC hierarchy. A left rail lists the company’s Accounts (4-digit, relationship/site level) with a search box plus an “Unlinked bill-tos” tab; selecting an account shows its linked 6-digit bill-to Customers on the right. Each customer row leads with its billing address + AP contact, surfaces its ship-to locations (where machines go) as a third tier, and shows a real activity signal — ARHIST last-invoiced date + a dormant flag (no invoice in 3+ years), which replaces the unreliable STATUS field. Data-quality hints (orphan bill-tos, duplicate-name clusters such as GE’s “GENERAL ELECTRIC” billing duplicates, mislinked/“wrong-account” bill-tos, “do not use”, shares-a-ship-to) drive a suggested vote.
Reviewers cast an FMK verdict per bill-to — keep / merge / kill / defer, plus link to place an orphan bill-to onto the correct account — with comments and inline pickers (merge-survivor for merge, target-account for link, defaulting to a ship-to match). The UI is framed as “your input — a vote, not a change”: verdicts are saved and reversible and never touch AFTEC or BC directly. They export as a source↔survivor crosswalk (CSV/JSON, with a linkToAccount column) that drives the AFTEC/BC cleanup and the CE load.
Engine reuse (no new API, no new DNS)
The analysis is not re-implemented. company_analysis.py already pulls AFTEC via the UniData API’s anonymous /dev endpoints and queries the Business Central virtual tables (dyn365bc_customer_v2_0) for coverage. A --json flag serializes that output; an analysis-refresh job --uploads it into the portal, which caches it and overlays the voting layer. No change to PSI.UniData.API; new route on the already-deployed portal, so no new DNS.
Data model (Azure SQL CalibrationPortal, self-bootstrapping)
| Table | Purpose |
|---|---|
recon_company_analysis | Cached company_analysis.py output, keyed by company |
recon_decisions | The FMK verdict per bill-to (keep/merge/kill/defer/link + merge-survivor + link-target account) |
recon_comments | Free-text flags / discussion, company- or record-level |
recon_activity | Append-only audit log of every ingest / decision / comment |
Schema self-applies on boot via ensureReconciliationSchema() — same idempotent pattern as the Label Designer.
API surface (/api/reconciliation/*)
All endpoints except /health require an Entra ID bearer token (and the reviewers group when RECON_REQUIRE_GROUP is set). The ingest route also accepts the refresh-job shared secret.
GET /api/reconciliation/health
GET /api/reconciliation/companies list (cached analyses + decided progress)
GET /api/reconciliation/companies/:key analysis + decisions + comments
PUT /api/reconciliation/companies/:key/analysis ingest (x-recon-ingest-secret, or reviewer token)
PUT /api/reconciliation/companies/:key/customers/:custNo/decision { disposition, mergeInto?, linkAccount?, reason? }
DELETE /api/reconciliation/companies/:key/customers/:custNo/decision clear verdict
POST /api/reconciliation/companies/:key/comments { custNo?, body }
GET /api/reconciliation/companies/:key/activity
GET /api/reconciliation/companies/:key/export?format=csv|json source↔survivor crosswalk
Configuration
| Variable | Prod value | Purpose |
|---|---|---|
RECON_DB_AUTH | managed-identity | mssql auth mode (App Service MI) |
RECON_DB_DATABASE | CalibrationPortal | shares the portal DB tier |
RECON_REQUIRE_GROUP | (set to group OID) | gates access to the reviewers group (enforced server-side) |
RECON_INGEST_SECRET | (set) | shared secret the analysis-refresh job uses to PUT analysis |
RECON_API_ALLOW_LOCAL_BYPASS | false | dev-only auth bypass |
Analysis refresh
A scheduled/triggered job on runner infra keeps the cache current — it runs the engine and uploads the result:
RECON_INGEST_SECRET=… python company_analysis.py "GE" \
--regex "(^ge[ /-]|general electric|…)" --name "GE / General Electric" \
--json --upload https://portal.progressivesurface.comProcisely Module
The portal includes the admin interface for Procisely.com, PSI’s URL shortener and asset link management platform. The Procisely admin is accessible at /procisely within the portal and via the Procisely card in the Internal Tools section of the home page.
Features
- Dashboard — summary stats, quick-create from template, recent activity
- Redirect Management — CRUD with filtering, bulk operations, CSV export
- Pattern Rules — regex-based runtime routing with live tester
- Templates — creation-time helpers for standardized redirect patterns
- QR Code Generation — single and batch, with optional PSI logo overlay, print-ready output
- Bulk Import — CSV upload or template-based serial import with combined QR download
- Analytics — click trends, top redirects, category breakdown
Database
| Setting | Value |
|---|---|
| Database | Procisely (on procserv-proddata) |
| Access | Portal managed identity with procisely_admin role |
| Env vars | PROCISELY_DB_SERVER, PROCISELY_DB_NAME, PROCISELY_DB_AUTH |
API Routes
All Procisely API endpoints are namespaced under /api/procisely/* with Entra ID bearer token authentication. Health check at /api/procisely/health does not require auth.
See Procisely wiki page for full API documentation, architecture, and security model.
Backup Posture
The calibration module is currently pre-production and the CalibrationPortal database has been right-sized to the cheapest viable tier:
| Setting | Value |
|---|---|
| Tier | Basic (5 DTU, 2 GB max) |
| Backup storage redundancy | Geo (Azure default) |
| Short-term retention | 7 days (Azure default for Basic) |
| Long-term retention | Disabled |
When the calibration module is promoted to a production workload, this DB should be moved back to General Purpose Serverless and LTR re-enabled (weekly/monthly/yearly retention as required by the data steward).
Network Architecture
Internet (blocked)
│
╳ (Public access denied)
│
┌──────┴────────────────────────────────────────┐
│ Azure App Service: psi-portal │
│ portal.progressivesurface.com │
└──────┬────────────────────────────────────────┘
│
│ Private Endpoint (10.160.0.6)
│ PS-SERVERS subnet
│
┌──────┴────────────────────────────────────────┐
│ PSI Internal Network │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Users access via: │ │
│ │ • Onsite workstations │ │
│ │ • VPN connection │ │
│ │ • M365 App Launcher │ │
│ └─────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
Applications Listed
The portal displays cards for the following external applications and internal sub-apps.
External application tiles
| App | Category | Status |
|---|---|---|
| PSI Explorer | Engineering | Live |
| Redbook Dashboard | Quality | Live |
| ERP Migration Tool | Data & Integration | Live |
| PSI Wiki | Administration | Live |
| UniData API | Data & Integration | Live |
Internal sub-apps hosted in this portal
| Sub-app | Route | Visibility | Status |
|---|---|---|---|
| Procisely admin | /procisely/* | All authenticated users | Live (production) |
| Calibration Portal | /quality/calibration | Alpha-gated (workbook on U:\ is still SoR) | Alpha — pre-cutover |
| Calibration Labels | /quality/calibration-labels | All authenticated users (open 2026-05-20) | Live |
| ZPL Lab | /quality/zpl-lab | Linked from Calibration Labels; no tile/nav | Dev/diagnostic |
| Label Designer | /quality/labels | All authenticated users (open 2026-05-20) | Beta |
| CapEx Portal | /finance/capex | Alpha-gated (single UPN) | Alpha — dormant |
| Customer Data Reconciliation | /finance/reconciliation | All authenticated users | Live |
| Badge Provisioning | /admin/badges | Direct URL only (nav tile removed) | Live (operator tool) |
Deployment
CI/CD Pipeline
Production deployment runs through GitHub Actions using identity-based Azure login on the PSI runner:
- Checkout code
- Setup Node.js 20
- Install dependencies (
npm ci) - Build (
npm run build) - Deploy to Azure Web App via
az login --identity
Local zip deploy / publish-profile style production deployment is not the compliant path for this app.
M365 Integration
PSI Portal is registered as an Enterprise Application in Entra ID:
- Display Name: PSI Portal
- App ID: 7f929c7f-2483-4206-93b6-11225e07ca85
- Home Page: https://portal.progressivesurface.com
Users can access the portal from:
- Direct URL
- M365 app launcher (waffle menu)
- my.apps.microsoft.com portal
Local Development
# Clone repository
git clone https://progressivesurface.ghe.com/ProgressiveSurface/psi-portal.git
cd psi-portal
# Install dependencies
npm install
# Start dev server
npm run dev
# Opens at http://localhost:5173
# Build for production
npm run buildDesign System
The portal uses the canonical PSI Design System (C:\git\psi-design-system\assets\ps.css), vendored into this repo at src/styles/ps.css. The brand color is #027A54 (Pantone 348 green) — not blue. Tailwind utilities (bg-psi-green, text-psi-ink, border-psi-border, etc.) bridge to canonical --ps-* tokens in the @theme block of src/index.css.
Color Palette (canonical)
| Token | Hex | Usage |
|---|---|---|
--ps-green-500 | 027A54 | Brand primary — headers, accents, focus rings |
--ps-ink | (dark) | Primary text — body, headings |
--ps-ink-3 | (mid) | Secondary text — labels, descriptions |
--ps-border | (light) | Surface borders |
--ps-surface | (white) | Card / tile background |
A back-compat bridge maps the legacy --color-psi-blue name to the green ink token, but do not use hardcoded hex in new code. Prefer the ps-* / psi-* Tailwind utilities or var(--ps-*) tokens.
Usage in components:
<header className="bg-psi-green text-white">
<div className="p-3 bg-white/10 rounded-xl">...</div>
</header>Future Enhancements
- Search functionality for apps
- User favorites/pinned apps
- System status from health endpoints
- Theme toggle (light/dark mode)
- Notifications for new apps/updates
Related Documentation
- data-brain - Data sources and systems
- deploy-to-azure - Deployment guide
- webapp-compliance-standard - PSI web app compliance baseline
- index - PSI Explorer documentation
- index - Desktop applications catalog