diff --git a/.env.example b/.env.example index 15e2970..6263201 100644 --- a/.env.example +++ b/.env.example @@ -79,3 +79,10 @@ ZABBIX_TOKEN= # CMMC_USB_DB_USER= # CMMC_USB_DB_PASSWORD= # CMMC_USB_DB_NAME=cmmc_usb + +# ---- Subpath deployment (optional) ---- +# Serve the app under a URL prefix instead of the server root, e.g. as an IIS +# Application at /ops under an existing site. The frontend must be rebuilt with +# the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the +# app owns its own site/port (the default). See docs/INSTALL-WINDOWS-IIS.md. +# MOUNT_PATH=/ops diff --git a/deploy/windows/web.config b/deploy/windows/web.config index 2c76842..fe712b0 100644 --- a/deploy/windows/web.config +++ b/deploy/windows/web.config @@ -38,6 +38,13 @@ config (SQL echo, debug, wrong DB URL). Real secrets go in .env. --> + diff --git a/docs/DEPLOY-WINDOWS-IIS.md b/docs/DEPLOY-WINDOWS-IIS.md index 46e86c6..6d7394a 100644 --- a/docs/DEPLOY-WINDOWS-IIS.md +++ b/docs/DEPLOY-WINDOWS-IIS.md @@ -128,6 +128,13 @@ to reproduce the exact enabled set, then just run `flask plugin upgrade-all`.) ## 6. Create the IIS site + web.config +This describes the own-site method (the app gets its own IIS site + port). To +mount the app at a subpath under an existing site instead (e.g. +`https:///ops/` sharing the classic site's binding and cert), see +**docs/INSTALL-WINDOWS-IIS.md section 7b**: same web.config, but the site is a +`New-WebApplication` under the parent, `MOUNT_PATH=/ops` is set (web.config or +`.env`), and the frontend is built with `VITE_BASE_PATH=/ops/`. + 1. In IIS Manager, add a new **Site** (separate from the classic ASP site): - Physical path: `APP_ROOT` - Binding: a free port or a dedicated hostname (e.g. `https` 443 with the diff --git a/docs/INSTALL-WINDOWS-IIS.md b/docs/INSTALL-WINDOWS-IIS.md index afe2143..f6a4b97 100644 --- a/docs/INSTALL-WINDOWS-IIS.md +++ b/docs/INSTALL-WINDOWS-IIS.md @@ -129,6 +129,16 @@ venv\Scripts\flask seed admin --username admin --email admin@yourfacility.exampl ## 7. IIS site +Two supported deployment methods: + +- **Method A - own site (recommended, default):** the app gets its own IIS + site, port (or hostname), app pool, and venv. Steps 1-5 below. +- **Method B - subpath under an existing site:** the app runs as an IIS + **Application** (e.g. `/ops`) under a site you already have (such as the + classic ASP site or Default Web Site), so it shares that site's binding and + TLS cert: `https:///ops/`. Do steps 1-4 below, then follow **7b** + instead of step 5. + 1. Copy `deploy\windows\web.config` to `APP_ROOT\web.config`. If `APP_ROOT` is not `C:\shopdb-flask`, fix the paths inside it. Create `APP_ROOT\logs`. 2. Create an app pool with **No Managed Code**: @@ -159,6 +169,32 @@ IIS launches `waitress-serve --port=%HTTP_PLATFORM_PORT% wsgi:app` per the web.config and reverse-proxies the site port to it. First request takes ~15s (the app boots + connects to MySQL). +### 7b. Method B: subpath under an existing site + +The mount path must match in **three places**: the IIS Application alias, the +`MOUNT_PATH` the backend sees, and the `VITE_BASE_PATH` the frontend was built +with. `/ops` is the example throughout; any alias works. + +1. Rebuild the frontend for the subpath (on the dev box, then copy `dist`): + ```bash + cd frontend && VITE_BASE_PATH=/ops/ npm run build # note the trailing slash + ``` +2. Create the Application under the existing site (instead of `New-Website`): + ```powershell + New-WebApplication -Site "Default Web Site" -Name ops -PhysicalPath APP_ROOT -ApplicationPool shopdbflask + ``` +3. Tell the backend its mount path: in `APP_ROOT\web.config`, uncomment the + `MOUNT_PATH` environment variable (value `/ops`), or set `MOUNT_PATH=/ops` + in `APP_ROOT\.env`. `wsgi.py` then serves everything under the prefix + (requests outside it get a plain 404 naming the mount). +4. Recycle the app pool. The app is at `http(s):///ops/` and the API at + `/ops/api/...`. + +The handler mappings in the app's web.config apply only inside the +Application, so the parent site's own handlers (classic ASP, static files) +are untouched. `CORS_ORIGINS` in `.env` is origin-only (scheme + host + port, +no path), so it is the same for both methods. + > The `X-Forwarded-For` URL Rewrite rule in web.config is **commented out by > default**. It needs the URL Rewrite module; with it active but the module > absent, IIS returns 500.19. Install URL Rewrite, then uncomment the @@ -191,4 +227,6 @@ each gets its own site, app pool, port, and venv. | **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. | | "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). | | Nav missing Equipment/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. | +| Method B: blank page / assets 404 under `/ops` | frontend `dist` built without `VITE_BASE_PATH=/ops/` (step 7b.1). | +| Method B: SPA loads but every API call 404s | `MOUNT_PATH` unset or not matching the Application alias (step 7b.3). | | ConfigError on boot | a required `.env` var missing or left at a dev default. | diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6079a53..e96c0cf 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,7 +1,10 @@ import axios from 'axios' +import { withBase } from './../utils/basePath' +// BASE_URL ends in '/', so this is '/api' at root or '/ops/api' under a subpath +// mount. Keeps the SPA, its API, and IIS all on the same mount path. const api = axios.create({ - baseURL: '/api', + baseURL: import.meta.env.BASE_URL + 'api', headers: { 'Content-Type': 'application/json' } @@ -31,10 +34,11 @@ api.interceptors.response.use( // Only redirect if user was previously logged in (session expired). // Preserve the destination so login returns the user to this page. if (hadToken) { + const loginPath = withBase('/login') const here = window.location.pathname + window.location.search - const target = here && here !== '/login' - ? '/login?redirect=' + encodeURIComponent(here) - : '/login' + const target = here && here !== loginPath + ? loginPath + '?redirect=' + encodeURIComponent(here) + : loginPath window.location.href = target } } diff --git a/frontend/src/composables/mapConfig.js b/frontend/src/composables/mapConfig.js index f95c28f..5009a5d 100644 --- a/frontend/src/composables/mapConfig.js +++ b/frontend/src/composables/mapConfig.js @@ -5,6 +5,7 @@ // install still renders; each site uploads its own blueprint in Settings. import { reactive } from 'vue' import { settingsApi } from '../api' +import { withBase } from '../utils/basePath' // Fallback defaults - match the seeded map_blueprint_* setting defaults. const DEFAULTS = { @@ -59,7 +60,7 @@ export function reloadMapConfig() { // Blueprint image URL for the given theme ('light' | 'dark'). export function blueprintUrlFor(theme) { - return theme === 'light' ? state.blueprintLight : state.blueprintDark + return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark) } export function useMapConfig() { diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 43cac00..ade1a1e 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -117,7 +117,7 @@ const routes = [ ] const router = createRouter({ - history: createWebHistory(), + history: createWebHistory(import.meta.env.BASE_URL), routes }) diff --git a/frontend/src/utils/basePath.js b/frontend/src/utils/basePath.js new file mode 100644 index 0000000..c8e6318 --- /dev/null +++ b/frontend/src/utils/basePath.js @@ -0,0 +1,13 @@ +// Single source for the app's mount path. Vite injects import.meta.env.BASE_URL +// from the build-time `base` (default '/', or e.g. '/ops/' for a subpath IIS +// mount). Every root-absolute URL to a Flask-served asset or route must go +// through withBase() so it resolves under the mount instead of the server root. +export const BASE_URL = import.meta.env.BASE_URL + +// Prefix a root-absolute path (e.g. '/ge-aerospace-logo.svg', '/api', '/tv') +// with the mount base. Leaves full URLs (http, data:) untouched. +export function withBase(path) { + if (!path) return path + if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path + return BASE_URL + String(path).replace(/^\//, '') +} diff --git a/frontend/src/utils/siteSettings.js b/frontend/src/utils/siteSettings.js index f6078e6..22180db 100644 --- a/frontend/src/utils/siteSettings.js +++ b/frontend/src/utils/siteSettings.js @@ -2,6 +2,7 @@ // The settings GET is public (jwt optional), so the kiosk dashboard and the // print views can read these without auth. Fetched once and cached per page. import { settingsApi } from '@/api' +import { withBase } from '@/utils/basePath' let settingsCache = null @@ -38,7 +39,7 @@ export async function getFacilityName() { // Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark. export async function getSiteLogo() { - return getSetting('site_logo', '/ge-aerospace-logo.svg') + return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg')) } // Logo composited into the center of printer QR codes. Empty = no overlay. @@ -47,17 +48,18 @@ export async function getSiteLogo() { export async function getQrLogo() { const settings = await loadSettings() const value = settings['qr_logo'] - return (value === undefined || value === null) ? '/ge-monogram.svg' : value + return withBase((value === undefined || value === null) ? '/ge-monogram.svg' : value) } // Logo printed on machine inspection badges. export async function getBadgeLogo() { - return getSetting('badge_logo', '/ge-aerospace-logo.svg') + return withBase(await getSetting('badge_logo', '/ge-aerospace-logo.svg')) } // Browser-tab favicon. Empty = keep the shipped /favicon.svg. export async function getFavicon() { - return getSetting('site_favicon', '') + const value = await getSetting('site_favicon', '') + return value ? withBase(value) : value } // Brand primary color override. Empty = built-in palette from style.css. diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index e101527..44e61ee 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -25,8 +25,8 @@ - Shopfloor Dashboard - TV Slideshow + Shopfloor Dashboard + TV Slideshow Settings @@ -107,6 +107,7 @@ import { useAuthStore } from '../stores/auth' import { currentTheme, toggleTheme } from '../stores/theme' import { dashboardApi, notificationsApi } from '../api' import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings' +import { withBase } from '../utils/basePath' const router = useRouter() const route = useRoute() @@ -118,7 +119,7 @@ const searchQuery = ref('') const navItems = ref([]) const activeNotifications = ref([]) const facilityName = ref('ShopDB') -const siteLogo = ref('/ge-aerospace-logo.svg') +const siteLogo = ref(withBase('/ge-aerospace-logo.svg')) const servicenowConfig = ref({ enabled: true, searchUrl: '' }) function getTicketSearchUrl(ticketnumber) { diff --git a/frontend/src/views/ChangePassword.vue b/frontend/src/views/ChangePassword.vue index 955722f..53273c5 100644 --- a/frontend/src/views/ChangePassword.vue +++ b/frontend/src/views/ChangePassword.vue @@ -41,6 +41,7 @@ import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' import { authApi } from '../api' import { getSiteLogo } from '../utils/siteSettings' +import { withBase } from '../utils/basePath' import { apiError } from '../utils/apiError' const router = useRouter() @@ -48,7 +49,7 @@ const authStore = useAuthStore() const forced = computed(() => authStore.mustChangePassword) -const siteLogo = ref('/ge-aerospace-logo.svg') +const siteLogo = ref(withBase('/ge-aerospace-logo.svg')) const currentPassword = ref('') const newPassword = ref('') const confirmPassword = ref('') diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 751d45b..c1f2f6c 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -52,6 +52,7 @@ import { useRouter, useRoute } from 'vue-router' import { useAuthStore } from '../stores/auth' import { setupApi } from '../api' import { getSiteLogo } from '../utils/siteSettings' +import { withBase } from '../utils/basePath' const router = useRouter() const route = useRoute() @@ -67,7 +68,7 @@ function postLoginTarget() { return '/' } -const siteLogo = ref('/ge-aerospace-logo.svg') +const siteLogo = ref(withBase('/ge-aerospace-logo.svg')) const mode = ref('login') const username = ref('') const email = ref('') diff --git a/frontend/src/views/ShopfloorDashboard.vue b/frontend/src/views/ShopfloorDashboard.vue index 5faa96a..149ead8 100644 --- a/frontend/src/views/ShopfloorDashboard.vue +++ b/frontend/src/views/ShopfloorDashboard.vue @@ -186,10 +186,11 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api' import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings' +import { withBase } from '@/utils/basePath' const loading = ref(true) const facilityName = ref('ShopDB') -const siteLogo = ref('/ge-aerospace-logo.svg') +const siteLogo = ref(withBase('/ge-aerospace-logo.svg')) // ServiceNow ticket-link config; loaded on mount. Empty/disabled = plain text. const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' }) const businessUnit = ref('') diff --git a/frontend/src/views/TVDashboard.vue b/frontend/src/views/TVDashboard.vue index 2785a4e..16c4ff4 100644 --- a/frontend/src/views/TVDashboard.vue +++ b/frontend/src/views/TVDashboard.vue @@ -7,7 +7,7 @@ class="slide" :class="{ active: idx === currentSlide }" > - +
@@ -28,6 +28,7 @@