Buildings and levels for the floor map, and make every identifier searchable
The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database.
This commit is contained in:
@@ -560,6 +560,7 @@ def create_computer():
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapx=data.get('mapx'),
|
||||
levelid=data.get('levelid'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
@@ -658,7 +659,7 @@ def update_computer(computer_id: int):
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||
'maintenancereference', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
old_val = getattr(asset, key)
|
||||
|
||||
@@ -261,9 +261,11 @@
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||
<span v-else class="position-level position-level-missing">level not set</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
@@ -272,8 +274,19 @@
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||
<label>Level</label>
|
||||
<select v-model.number="pickerLevelId" class="form-control">
|
||||
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||
:value="option.levelid">{{ option.label }}</option>
|
||||
</select>
|
||||
<span class="input-hint">
|
||||
The position is pixels on this drawing, so pick the level first.
|
||||
</span>
|
||||
</div>
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:levelid="pickerLevelId"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
@@ -306,6 +319,8 @@ import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
@@ -336,6 +351,10 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||
// a marker does not silently move it to the default one.
|
||||
const pickerLevelId = ref(null)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
@@ -356,6 +375,7 @@ const form = ref({
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
levelid: null,
|
||||
ipaddress: ''
|
||||
})
|
||||
|
||||
@@ -459,6 +479,7 @@ onMounted(async () => {
|
||||
notes: pc.notes || '',
|
||||
mapx: pc.mapx ?? null,
|
||||
mapy: pc.mapy ?? null,
|
||||
levelid: pc.levelid ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
}
|
||||
@@ -474,10 +495,21 @@ function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function openMapPicker() {
|
||||
loadMapConfig().then(() => {
|
||||
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||
showMapPicker.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
// Never one without the other: coordinates saved with no level render as
|
||||
// "level unknown", and coordinates saved against the wrong level render
|
||||
// convincingly in the wrong place.
|
||||
form.value.levelid = pickerLevelId.value
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
@@ -485,6 +517,7 @@ function confirmMapPosition() {
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
form.value.levelid = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "computers",
|
||||
"version": "1.0.0",
|
||||
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/computers",
|
||||
"provides": {
|
||||
"asset_type": "computer",
|
||||
"features": [
|
||||
"computer_tracking",
|
||||
"software_inventory",
|
||||
"remote_access",
|
||||
"os_management"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"enable_winrm": true,
|
||||
"enable_vnc": true,
|
||||
"auto_report_interval_hours": 24
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "computers",
|
||||
"version": "1.0.0",
|
||||
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.20.0,<1.0.0",
|
||||
"api_prefix": "/api/computers",
|
||||
"provides": {
|
||||
"asset_type": "computer",
|
||||
"features": [
|
||||
"computer_tracking",
|
||||
"software_inventory",
|
||||
"remote_access",
|
||||
"os_management"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"enable_winrm": true,
|
||||
"enable_vnc": true,
|
||||
"auto_report_interval_hours": 24
|
||||
}
|
||||
}
|
||||
|
||||
@@ -960,6 +960,7 @@ def _asset_facts(hostnames):
|
||||
'assetnumber': asset.assetnumber,
|
||||
'location': (asset.location.locationname if asset.location else None),
|
||||
'mapx': asset.mapx,
|
||||
'levelid': asset.levelid,
|
||||
'mapy': asset.mapy,
|
||||
'machinenumber': None, 'machineassetid': None,
|
||||
'machinepluginid': None,
|
||||
|
||||
@@ -21,13 +21,35 @@ from shopdb.api import require_permission, apply_import_timestamps
|
||||
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
||||
|
||||
|
||||
def _visible_articles():
|
||||
"""Active articles whose topic is not a retired application.
|
||||
|
||||
An article about a decommissioned application is not something anyone should
|
||||
find by browsing or searching: it describes a thing that is no longer in
|
||||
service, and presenting it alongside live documentation reads as though it
|
||||
were current.
|
||||
|
||||
An article with NO topic still shows. Not every article is about an
|
||||
application, and a null topic is not a retired one.
|
||||
|
||||
Expressed as a subquery rather than a join because the topic sort below joins
|
||||
Application itself, and two joins onto the same table in one query collide.
|
||||
"""
|
||||
retired = db.session.query(Application.appid).filter(
|
||||
Application.isactive.is_(False))
|
||||
return KnowledgeBase.query.filter(
|
||||
KnowledgeBase.isactive.is_(True),
|
||||
db.or_(KnowledgeBase.appid.is_(None),
|
||||
KnowledgeBase.appid.notin_(retired)))
|
||||
|
||||
|
||||
@knowledgebase_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_articles():
|
||||
"""List all knowledge base articles."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = KnowledgeBase.query.filter_by(isactive=True)
|
||||
query = _visible_articles()
|
||||
|
||||
# Search: title, keywords, and the topic (its Application's name). The topic
|
||||
# is matched via an appid subquery instead of a join so it does not collide
|
||||
@@ -35,8 +57,13 @@ def list_articles():
|
||||
# clause and still match on title/keywords.
|
||||
if search := request.args.get('search'):
|
||||
like = f'%{search}%'
|
||||
# Active applications only. A retired application is not a topic anyone
|
||||
# should be offered: matching its name surfaced its articles and printed
|
||||
# the retired app as their subject, which reads as though it were still
|
||||
# in service.
|
||||
topic_appids = db.session.query(Application.appid).filter(
|
||||
Application.appname.ilike(like))
|
||||
Application.appname.ilike(like),
|
||||
Application.isactive.is_(True))
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
KnowledgeBase.shortdescription.ilike(like),
|
||||
@@ -100,11 +127,11 @@ def list_articles():
|
||||
@jwt_required(optional=True)
|
||||
def get_stats():
|
||||
"""Get knowledge base statistics."""
|
||||
total_clicks = db.session.query(
|
||||
db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0)
|
||||
).filter(KnowledgeBase.isactive == True).scalar()
|
||||
|
||||
total_articles = KnowledgeBase.query.filter_by(isactive=True).count()
|
||||
# Counted over the same set the list shows. A total that includes articles
|
||||
# nobody can see is a total nobody can reconcile.
|
||||
visible = _visible_articles()
|
||||
total_clicks = sum(article.clicks or 0 for article in visible)
|
||||
total_articles = visible.count()
|
||||
|
||||
return success_response({
|
||||
'totalclicks': int(total_clicks),
|
||||
|
||||
@@ -106,7 +106,9 @@ const applications = ref([])
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load applications for topic dropdown
|
||||
const appsRes = await applicationsApi.list({ perpage: 1000 })
|
||||
const appsRes = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic:
|
||||
// ishidden governs whether an application shows on the tiles page, which
|
||||
// says nothing about whether it can be the subject of an article.
|
||||
applications.value = appsRes.data.data || []
|
||||
|
||||
// Load article if editing
|
||||
|
||||
@@ -171,7 +171,9 @@ async function loadArticles() {
|
||||
|
||||
async function loadTopics() {
|
||||
try {
|
||||
const response = await applicationsApi.list({ perpage: 1000 })
|
||||
const response = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic:
|
||||
// ishidden governs whether an application shows on the tiles page, which
|
||||
// says nothing about whether it can be the subject of an article.
|
||||
topics.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading topics:', error)
|
||||
|
||||
@@ -371,6 +371,7 @@ def create_machine():
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapx=data.get('mapx'),
|
||||
levelid=data.get('levelid'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
@@ -446,7 +447,7 @@ def update_machine(machine_id: int):
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
|
||||
'maintenancereference', 'serialnumber', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||
'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
|
||||
@@ -239,9 +239,11 @@
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||
<span v-else class="position-level position-level-missing">level not set</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
@@ -250,8 +252,19 @@
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||
<label>Level</label>
|
||||
<select v-model.number="pickerLevelId" class="form-control">
|
||||
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||
:value="option.levelid">{{ option.label }}</option>
|
||||
</select>
|
||||
<span class="input-hint">
|
||||
The position is pixels on this drawing, so pick the level first.
|
||||
</span>
|
||||
</div>
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:levelid="pickerLevelId"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
@@ -349,6 +362,8 @@ import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
@@ -371,6 +386,10 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||
// a marker does not silently move it to the default one.
|
||||
const pickerLevelId = ref(null)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
@@ -391,7 +410,8 @@ const form = ref({
|
||||
islocationonly: false,
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null
|
||||
mapy: null,
|
||||
levelid: null
|
||||
})
|
||||
|
||||
const machineTypes = ref([])
|
||||
@@ -501,7 +521,8 @@ onMounted(async () => {
|
||||
islocationonly: data.machine?.islocationonly || false,
|
||||
notes: data.notes || '',
|
||||
mapx: data.mapx ?? null,
|
||||
mapy: data.mapy ?? null
|
||||
mapy: data.mapy ?? null,
|
||||
levelid: data.levelid ?? null,
|
||||
}
|
||||
|
||||
// Load existing relationships to find controlling PC
|
||||
@@ -535,10 +556,21 @@ function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function openMapPicker() {
|
||||
loadMapConfig().then(() => {
|
||||
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||
showMapPicker.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
// Never one without the other: coordinates saved with no level render as
|
||||
// "level unknown", and coordinates saved against the wrong level render
|
||||
// convincingly in the wrong place.
|
||||
form.value.levelid = pickerLevelId.value
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
@@ -546,6 +578,7 @@ function confirmMapPosition() {
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
form.value.levelid = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"core_version": ">=0.20.0,<1.0.0",
|
||||
"api_prefix": "/api/machines",
|
||||
"provides": {
|
||||
"asset_type": "machine",
|
||||
|
||||
@@ -263,6 +263,7 @@ def create_tool():
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapx=data.get('mapx'),
|
||||
levelid=data.get('levelid'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes'),
|
||||
)
|
||||
@@ -291,7 +292,7 @@ def create_tool():
|
||||
# Asset core fields writable through this plugin's write path.
|
||||
_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference',
|
||||
'maintenancereference', 'serialnumber',
|
||||
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy',
|
||||
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||
'notes', 'isactive')
|
||||
# Extension fields with plain assignment (dates handled separately).
|
||||
_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.6.0,<1.0.0",
|
||||
"core_version": ">=0.20.0,<1.0.0",
|
||||
"api_prefix": "/api/measuringtools",
|
||||
"default_enabled": false,
|
||||
"provides": {
|
||||
|
||||
@@ -412,6 +412,7 @@ def create_network_device():
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapx=data.get('mapx'),
|
||||
levelid=data.get('levelid'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
@@ -502,7 +503,7 @@ def update_network_device(device_id: int):
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||
'maintenancereference', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
old_val = getattr(asset, key)
|
||||
|
||||
@@ -234,9 +234,11 @@
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||
<span v-else class="position-level position-level-missing">level not set</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
@@ -246,8 +248,19 @@
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||
<label>Level</label>
|
||||
<select v-model.number="pickerLevelId" class="form-control">
|
||||
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||
:value="option.levelid">{{ option.label }}</option>
|
||||
</select>
|
||||
<span class="input-hint">
|
||||
The position is pixels on this drawing, so pick the level first.
|
||||
</span>
|
||||
</div>
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:levelid="pickerLevelId"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
@@ -307,6 +320,8 @@ import {
|
||||
} from '@/api'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
@@ -315,6 +330,10 @@ import { apiError } from '@/utils/apiError'
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const showMapPicker = ref(false)
|
||||
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||
// a marker does not silently move it to the default one.
|
||||
const pickerLevelId = ref(null)
|
||||
const tempMapPosition = ref(null)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -348,6 +367,7 @@ const form = ref({
|
||||
ismanaged: false,
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
levelid: null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
@@ -459,6 +479,7 @@ async function loadDevice() {
|
||||
form.value.businessunitid = data.businessunitid || ''
|
||||
form.value.mapx = data.mapx
|
||||
form.value.mapy = data.mapy
|
||||
form.value.levelid = data.levelid
|
||||
form.value.notes = data.notes || ''
|
||||
// The IP lives in a Communication row, not on the extension table; the API
|
||||
// flattens it onto the response as ipaddress.
|
||||
@@ -508,6 +529,7 @@ async function submitForm() {
|
||||
ismanaged: form.value.ismanaged,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy,
|
||||
levelid: form.value.levelid,
|
||||
notes: form.value.notes || null
|
||||
}
|
||||
|
||||
@@ -550,10 +572,21 @@ function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function openMapPicker() {
|
||||
loadMapConfig().then(() => {
|
||||
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||
showMapPicker.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
// Never one without the other: coordinates saved with no level render as
|
||||
// "level unknown", and coordinates saved against the wrong level render
|
||||
// convincingly in the wrong place.
|
||||
form.value.levelid = pickerLevelId.value
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
@@ -561,6 +594,7 @@ function confirmMapPosition() {
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
form.value.levelid = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "network",
|
||||
"version": "1.0.0",
|
||||
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/network",
|
||||
"provides": {
|
||||
"asset_type": "network_device",
|
||||
"features": [
|
||||
"network_device_tracking",
|
||||
"port_management",
|
||||
"firmware_tracking",
|
||||
"poe_monitoring"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"enable_snmp_polling": false,
|
||||
"snmp_community": "public"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "network",
|
||||
"version": "1.0.0",
|
||||
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.20.0,<1.0.0",
|
||||
"api_prefix": "/api/network",
|
||||
"provides": {
|
||||
"asset_type": "network_device",
|
||||
"features": [
|
||||
"network_device_tracking",
|
||||
"port_management",
|
||||
"firmware_tracking",
|
||||
"poe_monitoring"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"enable_snmp_polling": false,
|
||||
"snmp_community": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +358,7 @@ def printer_install_list():
|
||||
'iscsf': printer.iscsf,
|
||||
'locationname': asset.location.locationname if asset.location else None,
|
||||
'mapx': asset.mapx,
|
||||
'levelid': asset.levelid,
|
||||
'mapy': asset.mapy,
|
||||
})
|
||||
|
||||
@@ -366,7 +367,7 @@ def printer_install_list():
|
||||
# hand-rolled JSON parser. The web map uses the default JSON.
|
||||
if request.args.get('format') == 'text':
|
||||
fields = ('printerid', 'windowsname', 'vendorname', 'modelnumber',
|
||||
'hostname', 'ipaddress', 'mapx', 'mapy')
|
||||
'hostname', 'ipaddress', 'mapx', 'mapy', 'levelid')
|
||||
lines = [_text_line(row, fields) for row in rows]
|
||||
return Response('\n'.join(lines), mimetype='text/plain')
|
||||
|
||||
@@ -738,6 +739,7 @@ def create_printer():
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapx=data.get('mapx'),
|
||||
levelid=data.get('levelid'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
@@ -819,7 +821,7 @@ def update_printer(printer_id: int):
|
||||
# Update asset fields (optional identifiers gated per-type in Settings)
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||
'maintenancereference', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||
'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
@@ -1035,6 +1037,7 @@ def _get_low_supplies_data():
|
||||
'model': model_number,
|
||||
'location': location_name,
|
||||
'mapx': asset.mapx,
|
||||
'levelid': asset.levelid,
|
||||
'mapy': asset.mapy,
|
||||
'supplies': annotated
|
||||
})
|
||||
|
||||
@@ -250,9 +250,11 @@
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||
<span v-else class="position-level position-level-missing">level not set</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
@@ -261,8 +263,19 @@
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||
<label>Level</label>
|
||||
<select v-model.number="pickerLevelId" class="form-control">
|
||||
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||
:value="option.levelid">{{ option.label }}</option>
|
||||
</select>
|
||||
<span class="input-hint">
|
||||
The position is pixels on this drawing, so pick the level first.
|
||||
</span>
|
||||
</div>
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:levelid="pickerLevelId"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
@@ -295,6 +308,8 @@ import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
@@ -320,6 +335,10 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||
// a marker does not silently move it to the default one.
|
||||
const pickerLevelId = ref(null)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
@@ -337,6 +356,7 @@ const form = ref({
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
levelid: null,
|
||||
// Printer-specific
|
||||
ipaddress: '',
|
||||
csfname: '',
|
||||
@@ -511,6 +531,7 @@ onMounted(async () => {
|
||||
notes: printer.notes || '',
|
||||
mapx: printer.mapx ?? null,
|
||||
mapy: printer.mapy ?? null,
|
||||
levelid: printer.levelid ?? null,
|
||||
// Printer-specific
|
||||
ipaddress: primaryComm?.ipaddress || '',
|
||||
csfname: ext.sharename || '',
|
||||
@@ -534,10 +555,21 @@ function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function openMapPicker() {
|
||||
loadMapConfig().then(() => {
|
||||
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||
showMapPicker.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
// Never one without the other: coordinates saved with no level render as
|
||||
// "level unknown", and coordinates saved against the wrong level render
|
||||
// convincingly in the wrong place.
|
||||
form.value.levelid = pickerLevelId.value
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
@@ -545,6 +577,7 @@ function confirmMapPosition() {
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
form.value.levelid = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '@/composables/mapConfig'
|
||||
import { loadMapConfig, blueprintUrlFor, dimensionsFor, state as mapConfig } from '@/composables/mapConfig'
|
||||
import { printersApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
@@ -55,8 +55,10 @@ const selected = ref({}) // printerid -> true
|
||||
let map = null
|
||||
let imageOverlay = null
|
||||
let markers = {} // printerid -> circleMarker
|
||||
let MAP_WIDTH = mapConfig.width
|
||||
let MAP_HEIGHT = mapConfig.height
|
||||
// The level being shown. The installer map runs before anyone logs in, which is
|
||||
// why /api/maplevels is public - without it there is no blueprint to draw.
|
||||
let MAP_WIDTH = 0
|
||||
let MAP_HEIGHT = 0
|
||||
|
||||
const SELECTED_COLOR = '#e53935'
|
||||
const NORMAL_COLOR = '#4CAF50'
|
||||
@@ -120,13 +122,13 @@ function renderMarkers() {
|
||||
}
|
||||
|
||||
watch(currentTheme, (theme) => {
|
||||
if (imageOverlay) imageOverlay.setUrl(blueprintUrlFor(theme))
|
||||
if (imageOverlay) imageOverlay.setUrl(blueprintUrlFor(theme, mapConfig.currentlevelid))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMapConfig()
|
||||
MAP_WIDTH = mapConfig.width
|
||||
MAP_HEIGHT = mapConfig.height
|
||||
MAP_WIDTH = dimensionsFor(mapConfig.currentlevelid).width
|
||||
MAP_HEIGHT = dimensionsFor(mapConfig.currentlevelid).height
|
||||
|
||||
try {
|
||||
const response = await printersApi.installList()
|
||||
@@ -142,7 +144,7 @@ onMounted(async () => {
|
||||
attributionControl: false,
|
||||
})
|
||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||
imageOverlay = L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
|
||||
imageOverlay = L.imageOverlay(blueprintUrlFor(currentTheme.value, mapConfig.currentlevelid), bounds).addTo(map)
|
||||
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], -2)
|
||||
map.setMaxBounds(bounds)
|
||||
renderMarkers()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.16.0,<1.0.0",
|
||||
"core_version": ">=0.20.0,<1.0.0",
|
||||
"api_prefix": "/api/printers",
|
||||
"provides": {
|
||||
"machine_category": "Printer",
|
||||
|
||||
@@ -88,6 +88,7 @@ def _related_machine(asset):
|
||||
# worth showing, so the caller decides what to do with a missing
|
||||
# position rather than the row being dropped.
|
||||
'mapx': candidate.mapx,
|
||||
'levelid': candidate.levelid,
|
||||
'mapy': candidate.mapy,
|
||||
'locationid': candidate.locationid,
|
||||
'locationname': (candidate.location.locationname
|
||||
|
||||
Reference in New Issue
Block a user