The board was one card per source in a fixed grid, and both halves of that hurt. Every source got the same small box whatever it had to say. Warranties showed 5 of 50 behind "and 45 more"; the crash card truncated at 4; a one-row card sat beside a four-row card and left a hole. Nothing had room, which is the complaint that started this: not enough space to relay the data. And a box per source flattened priority. An enforcement failure and a warranty notice looked equally urgent because each owned a card, while the largest type on the page belonged to counts that are true every day and need nobody. WHAT THE PAGE IS NOW A severity rollup, one worst-first queue of everything that needs a person, and a row of context beneath it. The queue merges every critical and warning row and sorts them, so reading order is work order. It is full width, which is where the room came from. Rows are capped per source, because fifty expiring warranties must not bury two crashed PCs, and what did not fit is named per source with a link to it - a bare "and 45 more" that leads nowhere is worse than saying nothing. The rollup counts ROWS, not cards, and filters the board in place rather than navigating away, so the counts and the rows cannot disagree. Severity, position and permission were already declared per card and already sorted on. The old layout threw that away; this only stops it. WHAT WENT, AND WHY IT SHOULD NOT COME BACK Four asset tiles plus an "All assets" tile: machines + PCs + printers + network IS all assets, so the page showed a total beside its own addends at the same weight. It is now a line under a rule, where a total belongs. The printer strip. It carried the printer count a third time - the tile row and "Total Printers" were already the same number - and "Online" equalled total, so three tiles held one fact. A genuinely low cartridge already reaches the queue through the printers-supplies card, which is the moment anyone wants it. Three cards whose whole job was to display 0. Recent Devices: the largest block on the page, listing five machines that were all "In Use" with business unit "TBD". Recency is not a reason to look at something. CONTRACT 0.26.0: 'shortname' Cards may declare a short label for their rows in the merged queue. Their 'title' is written as a heading - "Endpoints with crashes or shutdowns" - and as a row badge it ran wider than the hostname it qualified, putting the badge rather than the asset at the start of every line. CSS-truncating the title was tried first and was worse: four consecutive rows reading "ENDPOINTS WITH CR..." say nothing at all. Optional and defaults to the title, so a plugin that does not declare one is unaffected. Additive field, minor bump. ALSO: "silent 1 days" A meta suffix is written for the plural case and the card cannot know the value. One row saying it was a shrug; the merged queue puts every quiet endpoint on one screen, so it said it five times in a column and read as broken. Now singularised at exactly 1, on the first word, keeping what follows: "1 event (48h)", "silent 1 day". The test asserting ' ms' survives is what caught the first version turning it into ' m'. DashboardRow.vue is extracted because the queue and the context panels render the same row, and the map tooltip, link fallbacks and chips are too easy to get subtly different in a second copy. Verified on screen against live data. Full suite 2178 passed, frontend 301.
249 lines
8.7 KiB
Python
249 lines
8.7 KiB
Python
"""Machines plugin main class."""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Type
|
|
|
|
from flask import Flask, Blueprint
|
|
import click
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
from shopdb.api import db, AssetType, AssetStatus
|
|
|
|
from .models import Machine, MachineType
|
|
from .api import machines_bp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MachinesPlugin(BasePlugin):
|
|
"""
|
|
Machines plugin - manages manufacturing machine assets.
|
|
|
|
Machines include CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
|
|
Uses the new Asset architecture with Machine extension table.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._manifest = self._load_manifest()
|
|
|
|
def _load_manifest(self) -> Dict:
|
|
"""Load plugin manifest from JSON file."""
|
|
manifestpath = Path(__file__).parent / 'manifest.json'
|
|
if manifestpath.exists():
|
|
with open(manifestpath, 'r') as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
"""Return plugin metadata."""
|
|
return PluginMeta(
|
|
name=self._manifest.get('name', 'machines'),
|
|
version=self._manifest.get('version', '1.0.0'),
|
|
description=self._manifest.get(
|
|
'description',
|
|
'Machine management for manufacturing assets'
|
|
),
|
|
author=self._manifest.get('author', 'ShopDB Team'),
|
|
dependencies=self._manifest.get('dependencies', []),
|
|
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
|
api_prefix=self._manifest.get('api_prefix', '/api/machines'),
|
|
)
|
|
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
"""Return Flask Blueprint with API routes."""
|
|
return machines_bp
|
|
|
|
def get_models(self) -> List[Type]:
|
|
"""Return list of SQLAlchemy model classes."""
|
|
return [Machine, MachineType]
|
|
|
|
def init_app(self, app: Flask, db_instance) -> None:
|
|
"""Initialize plugin with Flask app."""
|
|
logger.info(f"Machines plugin initialized (v{self.meta.version})")
|
|
|
|
def on_install(self, app: Flask) -> None:
|
|
"""Called when plugin is installed."""
|
|
with app.app_context():
|
|
self._ensure_asset_type()
|
|
self._ensure_asset_statuses()
|
|
self._ensure_machine_types()
|
|
logger.info("Machines plugin installed")
|
|
|
|
def _ensure_asset_type(self) -> None:
|
|
"""Ensure machine asset type exists."""
|
|
existing = AssetType.query.filter_by(assettype='machine').first()
|
|
if not existing:
|
|
at = AssetType(
|
|
assettype='machine',
|
|
pluginname='machines',
|
|
tablename='machines',
|
|
description='Manufacturing machines (CNCs, CMMs, lathes, etc.)',
|
|
icon='cog'
|
|
)
|
|
db.session.add(at)
|
|
logger.debug("Created asset type: machine")
|
|
db.session.commit()
|
|
|
|
def _ensure_asset_statuses(self) -> None:
|
|
"""Ensure standard asset statuses exist."""
|
|
statuses = [
|
|
('In Use', 'Asset is currently in use', '#28a745'),
|
|
('Spare', 'Spare/backup asset', '#17a2b8'),
|
|
('Retired', 'Asset has been retired', '#6c757d'),
|
|
('Maintenance', 'Asset is under maintenance', '#ffc107'),
|
|
('Decommissioned', 'Asset has been decommissioned', '#dc3545'),
|
|
]
|
|
|
|
for name, description, color in statuses:
|
|
existing = AssetStatus.query.filter_by(status=name).first()
|
|
if not existing:
|
|
s = AssetStatus(
|
|
status=name,
|
|
description=description,
|
|
color=color
|
|
)
|
|
db.session.add(s)
|
|
logger.debug(f"Created asset status: {name}")
|
|
|
|
db.session.commit()
|
|
|
|
def _ensure_machine_types(self) -> None:
|
|
"""Ensure basic machine types exist."""
|
|
machine_types = [
|
|
('CNC', 'Computer Numerical Control machine', 'cnc'),
|
|
('CMM', 'Coordinate Measuring Machine', 'cmm'),
|
|
('Lathe', 'Lathe machine', 'lathe'),
|
|
('Grinder', 'Grinding machine', 'grinder'),
|
|
('EDM', 'Electrical Discharge Machine', 'edm'),
|
|
('Part Marker', 'Part marking/engraving machine', 'marker'),
|
|
('Mill', 'Milling machine', 'mill'),
|
|
('Press', 'Press machine', 'press'),
|
|
('Robot', 'Industrial robot', 'robot'),
|
|
('Other', 'Other machine type', 'cog'),
|
|
]
|
|
|
|
for name, description, icon in machine_types:
|
|
existing = MachineType.query.filter_by(machinetype=name).first()
|
|
if not existing:
|
|
mt = MachineType(
|
|
machinetype=name,
|
|
description=description,
|
|
icon=icon
|
|
)
|
|
db.session.add(mt)
|
|
logger.debug(f"Created machine type: {name}")
|
|
|
|
db.session.commit()
|
|
|
|
def on_uninstall(self, app: Flask) -> None:
|
|
"""Called when plugin is uninstalled."""
|
|
logger.info("Machines plugin uninstalled")
|
|
|
|
def get_cli_commands(self) -> List:
|
|
"""Return CLI commands for this plugin."""
|
|
|
|
@click.group('machines')
|
|
def machinescli():
|
|
"""Machines plugin commands."""
|
|
pass
|
|
|
|
@machinescli.command('list-types')
|
|
def list_types():
|
|
"""List all machine types."""
|
|
from flask import current_app
|
|
|
|
with current_app.app_context():
|
|
types = MachineType.query.filter_by(isactive=True).all()
|
|
if not types:
|
|
click.echo('No machine types found.')
|
|
return
|
|
|
|
click.echo('Machine Types:')
|
|
for t in types:
|
|
click.echo(f" [{t.machinetypeid}] {t.machinetype}")
|
|
|
|
@machinescli.command('stats')
|
|
def stats():
|
|
"""Show machine statistics."""
|
|
from flask import current_app
|
|
from shopdb.api import Asset
|
|
|
|
with current_app.app_context():
|
|
total = db.session.query(Machine).join(Asset).filter(
|
|
Asset.isactive == True
|
|
).count()
|
|
|
|
click.echo(f"Total active machines: {total}")
|
|
|
|
# By type
|
|
by_type = db.session.query(
|
|
MachineType.machinetype,
|
|
db.func.count(Machine.machineid)
|
|
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
|
|
).join(Asset, Asset.assetid == Machine.assetid
|
|
).filter(Asset.isactive == True
|
|
).group_by(MachineType.machinetype
|
|
).all()
|
|
|
|
if by_type:
|
|
click.echo("\nBy Type:")
|
|
for t, c in by_type:
|
|
click.echo(f" {t}: {c}")
|
|
|
|
return [machinescli]
|
|
|
|
def get_dashboard_widgets(self) -> List[Dict]:
|
|
"""Dashboard card: machines not in service.
|
|
|
|
A machine sitting in Repair or marked Lost is a thing someone is
|
|
supposed to be chasing, and nothing surfaces it today - it is visible
|
|
only to whoever thinks to filter the machines list by status.
|
|
|
|
Replaces a declaration naming a component nobody wrote.
|
|
"""
|
|
return [
|
|
{
|
|
'id': 'machines-outofservice',
|
|
'viewall': '/machines',
|
|
'title': 'Machines out of service',
|
|
'shortname': 'Out of service',
|
|
'endpoint': '/api/machines/dashboard/outofservice',
|
|
'render': 'exceptions',
|
|
'severity': 'warning',
|
|
'permission': 'machines.view',
|
|
'empty': 'hide',
|
|
'position': 45,
|
|
'map': {
|
|
'title': 'assetnumber',
|
|
'detail': 'status',
|
|
'meta': [{'key': 'name'}],
|
|
'link': '/machines/{assetid}',
|
|
},
|
|
},
|
|
]
|
|
|
|
def get_navigation_items(self) -> List[Dict]:
|
|
"""Return navigation menu items."""
|
|
return [
|
|
{
|
|
'name': 'Machines',
|
|
'icon': 'cog',
|
|
'route': '/machines',
|
|
'permission': 'machines.view',
|
|
'position': 10,
|
|
'section': 'assets',
|
|
},
|
|
]
|
|
|
|
def get_permissions(self) -> List:
|
|
"""Return the RBAC permissions this plugin owns."""
|
|
return [
|
|
('machines.view', 'View machines', 'machines'),
|
|
('machines.create', 'Create machines', 'machines'),
|
|
('machines.edit', 'Edit machines', 'machines'),
|
|
('machines.delete', 'Delete machines', 'machines'),
|
|
]
|