PSI Portal

The PSI Portal is the central landing page and application launcher for all PSI web applications.

ResourceURL
Productionhttps://portal.progressivesurface.com
RepositoryProgressiveSurface/psi-portal
Azure Apppsi-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

ComponentTechnology
FrameworkReact 19
LanguageTypeScript
Build ToolVite
StylingTailwindCSS v4
IconsLucide React
API RuntimeNode.js / Express

Production Configuration

SettingValue
Azure App Servicepsi-portal
Resource GroupPS-WEBAPPS
App Service Planasp-erp-migration-tool
RuntimeNode 20 LTS (Linux)
Custom Domainportal.progressivesurface.com
Private Endpoint IP10.160.0.6
Entra App ID7f929c7f-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 approved psi-portal auth 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 localStorage so 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:
    • / returns 200
    • calibration APIs return 401 when 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 CalibrationPortal Azure 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.

SettingValue
API endpointhttps://api.labelary.com/v1/printers/12dpmm/labels/2x1.25/0/
DPI parameter12dpmm (≈ 304 DPI — closest Labelary supports to our 300 DPI ZD621)
Label size2x1.25 (width × height in inches)
Request methodPOST with raw ZPL as the body, Accept: image/png
Timeout8 seconds (AbortSignal.timeout)

How it works:

  1. Browser sends form data to POST /api/calibration-labels/preview (Express server)
  2. Server builds ZPL from the form fields using buildCalibrationLabelZpl()
  3. Server POSTs the raw ZPL string to Labelary and returns the PNG to the browser
  4. 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.

RowFieldJustification
1S/NCenter
2Cal date / Exp dateLeft / Right
3Calibration IDCenter
4Calibrated ByCenter

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 CalibrationPortal Azure 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

PathPurpose
/quality/labelsTemplate gallery (list, new, copy, archive)
/quality/labels/newEditor — new template
/quality/labels/:id/editEditor — 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):

VariableDefaultPurpose
LABELS_DB_AUTHmanaged-identitymssql auth mode
LABELS_DB_SERVERprocserv-proddata.database.windows.netAzure SQL host
LABELS_DB_DATABASECalibrationPortalreuses the calibration DB; labels and calibration share a tier
LABELS_AUTH_CLIENT_ID7f929c7f-…portal Entra app
LABELS_AUTH_TENANT_IDa83ae943-…PSI tenant
LABELS_API_ALLOW_LOCAL_BYPASSfalsedev-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 exit

What 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)

TablePurpose
recon_company_analysisCached company_analysis.py output, keyed by company
recon_decisionsThe FMK verdict per bill-to (keep/merge/kill/defer/link + merge-survivor + link-target account)
recon_commentsFree-text flags / discussion, company- or record-level
recon_activityAppend-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

VariableProd valuePurpose
RECON_DB_AUTHmanaged-identitymssql auth mode (App Service MI)
RECON_DB_DATABASECalibrationPortalshares 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_BYPASSfalsedev-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.com

Procisely 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

SettingValue
DatabaseProcisely (on procserv-proddata)
AccessPortal managed identity with procisely_admin role
Env varsPROCISELY_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:

SettingValue
TierBasic (5 DTU, 2 GB max)
Backup storage redundancyGeo (Azure default)
Short-term retention7 days (Azure default for Basic)
Long-term retentionDisabled

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

AppCategoryStatus
PSI ExplorerEngineeringLive
Redbook DashboardQualityLive
ERP Migration ToolData & IntegrationLive
PSI WikiAdministrationLive
UniData APIData & IntegrationLive

Internal sub-apps hosted in this portal

Sub-appRouteVisibilityStatus
Procisely admin/procisely/*All authenticated usersLive (production)
Calibration Portal/quality/calibrationAlpha-gated (workbook on U:\ is still SoR)Alpha — pre-cutover
Calibration Labels/quality/calibration-labelsAll authenticated users (open 2026-05-20)Live
ZPL Lab/quality/zpl-labLinked from Calibration Labels; no tile/navDev/diagnostic
Label Designer/quality/labelsAll authenticated users (open 2026-05-20)Beta
CapEx Portal/finance/capexAlpha-gated (single UPN)Alpha — dormant
Customer Data Reconciliation/finance/reconciliationAll authenticated usersLive
Badge Provisioning/admin/badgesDirect 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:

  1. Checkout code
  2. Setup Node.js 20
  3. Install dependencies (npm ci)
  4. Build (npm run build)
  5. 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:

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 build

Design 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)

TokenHexUsage
--ps-green-500027A54Brand 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