Support subpath IIS deployment as a second install method
Some checks failed
CI / backend (push) Successful in 1m41s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s

The app can run as an IIS Application under an existing site
(e.g. https://host/ops/) instead of its own site + port:

- frontend: vite base via VITE_BASE_PATH; router history, axios
  baseURL, and root-absolute asset/route paths resolve through
  utils/basePath.js withBase()
- backend: MOUNT_PATH (env or .env) wraps the app in a WSGI
  middleware that shifts the prefix into SCRIPT_NAME, so one knob
  serves API + SPA under the mount
- docs: INSTALL-WINDOWS-IIS.md section 7b runbook + troubleshooting
  rows; DEPLOY-WINDOWS-IIS.md pointer; commented examples in
  deploy/windows/web.config and .env.example

Root deployment unchanged (MOUNT_PATH unset, base '/'). Also folds
two stray root-absolute callers into the shared plumbing
(MachineForm relationship-types fetch, reports CSV window.open).
This commit is contained in:
cproudlock
2026-07-13 16:11:12 -04:00
parent 69dd6d0abe
commit 6010f01de1
19 changed files with 143 additions and 25 deletions

View File

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

View File

@@ -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() {

View File

@@ -117,7 +117,7 @@ const routes = [
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory(import.meta.env.BASE_URL),
routes
})

View File

@@ -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(/^\//, '')
}

View File

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

View File

@@ -25,8 +25,8 @@
</template>
<div class="nav-section">Displays</div>
<a href="/shopfloor" target="_blank" class="external-link">Shopfloor Dashboard</a>
<a href="/tv" target="_blank" class="external-link">TV Slideshow</a>
<a :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
<a :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
</nav>
@@ -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) {

View File

@@ -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('')

View File

@@ -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('')

View File

@@ -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('')

View File

@@ -7,7 +7,7 @@
class="slide"
:class="{ active: idx === currentSlide }"
>
<img :src="basePath + slide.filename" :alt="slide.filename" />
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
</div>
<div v-if="error" class="error-message">
@@ -28,6 +28,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import api from '@/api'
import { withBase } from '@/utils/basePath'
const INTERVAL = 10 // seconds between slides

View File

@@ -347,7 +347,7 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
@@ -463,11 +463,8 @@ onMounted(async () => {
// Load relationship types separately
try {
const relRes = await fetch('/api/assets/relationshiptypes')
if (relRes.ok) {
const relData = await relRes.json()
relationshipTypes.value = relData.data || []
}
const relRes = await relationshipTypesApi.list()
relationshipTypes.value = relRes.data.data || []
} catch (e) {
// Fallback - use hardcoded Controls type
relationshipTypes.value = [{ relationshiptypeid: 1, relationshiptype: 'Controls' }]

View File

@@ -30,6 +30,7 @@ import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { machinesApi } from '../../api'
import { getBadgeLogo } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath'
import JsBarcode from 'jsbarcode'
const route = useRoute()
@@ -37,7 +38,7 @@ const loading = ref(true)
const machine = ref(null)
const barcodeEl = ref(null)
const geLogo = ref('/ge-aerospace-logo.svg')
const geLogo = ref(withBase('/ge-aerospace-logo.svg'))
const isInspection = computed(() => {
if (!machine.value) return false

View File

@@ -220,6 +220,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
import { withBase } from '@/utils/basePath'
const router = useRouter()
const route = useRoute()
@@ -418,7 +419,7 @@ async function runReport(report) {
function exportCSV() {
if (!currentReport.value) return
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
window.open(`/api/reports/${currentReport.value.id}?${params}`, '_blank')
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank')
}
function clearReport() {