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 @@
Displays
- 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 @@