Tools for printer discovery and monitoring: - snmp_scanner.py: SNMP-based printer discovery - generate_printer_templates.py: Generate Zabbix templates - analyze_supplies.py: Analyze printer supply levels - extract_summary.py: Extract printer data summaries Includes Zabbix templates for: - HP Color/Mono printers - HP DesignJet T1700 - Xerox Color/Mono/Enterprise printers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2648 lines
112 KiB
Python
2648 lines
112 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Extract key printer information from SNMP scan CSVs
|
|
Converts all toner levels to percentages (0-100%)
|
|
"""
|
|
|
|
import csv
|
|
import os
|
|
import sys
|
|
import re
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
|
|
def clean_value(value):
|
|
"""Clean up encoding issues in SNMP values"""
|
|
if not value:
|
|
return value
|
|
|
|
# Remove common encoding prefixes
|
|
if value.startswith('ýè'):
|
|
value = value[2:]
|
|
|
|
# Remove null characters and other control chars
|
|
value = ''.join(char for char in value if ord(char) >= 32 or char in '\n\r\t')
|
|
|
|
return value.strip()
|
|
|
|
|
|
def extract_toner_info(csv_file):
|
|
"""
|
|
Extract comprehensive toner and supply information from CSV
|
|
Maps supply indices to their levels and capacities
|
|
"""
|
|
supplies = {}
|
|
toner_levels_raw = {} # Raw values from 43.11.1.1.9
|
|
toner_max_capacity = {} # Max capacity from 43.11.1.1.8
|
|
supply_current = {} # Current from 43.10.2.1.9
|
|
supply_max = {} # Max from 43.10.2.1.4
|
|
hp_designjet_levels = {} # HP DesignJet ink levels from proprietary OID
|
|
|
|
with open(csv_file, 'r', encoding='utf-8') as f:
|
|
reader = csv.DictReader(f)
|
|
|
|
for row in reader:
|
|
oid = row['OID']
|
|
value = clean_value(row['Value'])
|
|
|
|
# Extract supply descriptions (43.11.1.1.6)
|
|
if '1.3.6.1.2.1.43.11.1.1.6' in oid:
|
|
index = oid.split('.')[-1]
|
|
if index not in supplies:
|
|
supplies[index] = {}
|
|
supplies[index]['description'] = value
|
|
|
|
# Extract toner max capacity (43.11.1.1.8.1.X)
|
|
elif '1.3.6.1.2.1.43.11.1.1.8.1' in oid:
|
|
parts = oid.split('.')
|
|
if len(parts) >= 2:
|
|
index = parts[-1]
|
|
try:
|
|
toner_max_capacity[index] = int(value)
|
|
except:
|
|
pass
|
|
|
|
# Extract supply max capacity (43.10.2.1.4)
|
|
elif '1.3.6.1.2.1.43.10.2.1.4' in oid:
|
|
parts = oid.split('.')
|
|
if len(parts) >= 2:
|
|
index = parts[-1]
|
|
try:
|
|
supply_max[index] = int(value)
|
|
except:
|
|
pass
|
|
|
|
# Extract supply current level (43.10.2.1.9)
|
|
elif '1.3.6.1.2.1.43.10.2.1.9' in oid:
|
|
parts = oid.split('.')
|
|
if len(parts) >= 2:
|
|
index = parts[-1]
|
|
try:
|
|
supply_current[index] = int(value)
|
|
except:
|
|
pass
|
|
|
|
# Direct toner level readings (43.11.1.1.9.1.X)
|
|
elif '1.3.6.1.2.1.43.11.1.1.9.1' in oid:
|
|
parts = oid.split('.')
|
|
if len(parts) >= 2:
|
|
index = parts[-1]
|
|
try:
|
|
toner_levels_raw[index] = int(value)
|
|
except:
|
|
pass
|
|
|
|
# HP DesignJet ink levels (1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.5.4.X.1.0)
|
|
elif '1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.5.4.' in oid:
|
|
parts = oid.split('.')
|
|
# OID format: 1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.5.4.{cartridge_index}.1.0
|
|
# Example: 1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.5.4.1.1.0 (Matte Black)
|
|
if len(parts) >= 18:
|
|
index = parts[17] # Cartridge index (1-6)
|
|
try:
|
|
hp_designjet_levels[index] = int(value)
|
|
except:
|
|
pass
|
|
|
|
return supplies, toner_levels_raw, toner_max_capacity, supply_current, supply_max, hp_designjet_levels
|
|
|
|
|
|
def map_hp730_color(part_number):
|
|
"""
|
|
Map HP 730 part numbers to actual ink colors
|
|
Returns color name or None if not recognized
|
|
"""
|
|
part_number = part_number.upper().strip()
|
|
|
|
# HP 730 130ml and 300ml cartridges
|
|
hp730_map = {
|
|
# 130ml cartridges
|
|
'P2V62A': 'Cyan',
|
|
'P2V63A': 'Magenta',
|
|
'P2V64A': 'Yellow',
|
|
'P2V65A': 'Matte Black',
|
|
'P2V66A': 'Photo Black',
|
|
'P2V67A': 'Gray',
|
|
# 300ml cartridges (verified from actual T1700dr installation)
|
|
'P2V68A': 'Cyan',
|
|
'P2V69A': 'Magenta',
|
|
'P2V70A': 'Yellow',
|
|
'P2V71A': 'Matte Black',
|
|
'P2V72A': 'Gray',
|
|
'P2V73A': 'Photo Black',
|
|
}
|
|
|
|
# Extract part number from description like "HP 730 ( P2V72A )"
|
|
for pn, color in hp730_map.items():
|
|
if pn in part_number:
|
|
return color
|
|
|
|
return None
|
|
|
|
|
|
def calculate_toner_percentage(toner_idx, toner_value, toner_max_capacity, supply_current, supply_max, printer_model=''):
|
|
"""
|
|
Calculate toner percentage from various sources
|
|
Returns: (percentage, display_string)
|
|
"""
|
|
# Negative values mean not installed
|
|
if toner_value < 0:
|
|
return (None, "Not installed")
|
|
|
|
# If value is 0-100, it's already a percentage
|
|
if 0 <= toner_value <= 100:
|
|
return (toner_value, f"{toner_value}%")
|
|
|
|
# First try: Use toner max capacity (43.11.1.1.8.1.X) if available
|
|
# This is the correct OID for HP printers that report absolute page counts
|
|
if toner_idx in toner_max_capacity:
|
|
max_val = toner_max_capacity[toner_idx]
|
|
if max_val > 0:
|
|
percentage = int((toner_value / max_val) * 100)
|
|
return (percentage, f"{percentage}% ({toner_value:,}/{max_val:,})")
|
|
|
|
# Fallback: Use known static max capacities for specific printer models
|
|
# This handles cases where the max OID isn't scanned
|
|
model_lower = printer_model.lower()
|
|
known_max = None
|
|
if 'laserjet 4250' in model_lower:
|
|
known_max = 20000 # HP LaserJet 4250 (Q5942X cartridge)
|
|
elif 'laserjet 4200' in model_lower:
|
|
known_max = 12000 # HP LaserJet 4200 (Q1338A cartridge)
|
|
elif 'laserjet 4300' in model_lower:
|
|
known_max = 18000 # HP LaserJet 4300 (Q1339A cartridge)
|
|
elif 'laserjet 4350' in model_lower:
|
|
known_max = 20000 # HP LaserJet 4350 (Q5942X cartridge)
|
|
|
|
if known_max and toner_value > 100: # Only use if value is absolute pages
|
|
percentage = int((toner_value / known_max) * 100)
|
|
return (percentage, f"{percentage}% ({toner_value:,}/{known_max:,})")
|
|
|
|
# Try to find matching supply data to calculate percentage
|
|
# Often toner index '1' = supply '1', '2' = supply '2', etc.
|
|
if toner_idx in supply_max and toner_idx in supply_current:
|
|
max_val = supply_max[toner_idx]
|
|
current_val = supply_current[toner_idx]
|
|
if max_val > 0:
|
|
percentage = int((current_val / max_val) * 100)
|
|
return (percentage, f"{percentage}% ({current_val:,}/{max_val:,})")
|
|
|
|
# Alternative: Try supply index '1' for the first toner
|
|
if '1' in supply_max and '1' in supply_current:
|
|
max_val = supply_max['1']
|
|
current_val = supply_current['1']
|
|
if max_val > 0 and toner_value > 100:
|
|
# Estimate percentage based on pages
|
|
percentage = int((toner_value / max_val) * 100)
|
|
if percentage > 100:
|
|
# Raw value is absolute pages, not related to this max
|
|
# Just show the raw percentage estimate
|
|
# Assume typical cartridge is ~3000-20000 pages
|
|
if toner_value < 100:
|
|
return (toner_value, f"{toner_value}%")
|
|
elif toner_value < 500:
|
|
return (10, f"~10% ({toner_value:,} pages remaining)")
|
|
elif toner_value < 2000:
|
|
return (25, f"~25% ({toner_value:,} pages remaining)")
|
|
elif toner_value < 5000:
|
|
return (50, f"~50% ({toner_value:,} pages remaining)")
|
|
elif toner_value < 10000:
|
|
return (75, f"~75% ({toner_value:,} pages remaining)")
|
|
else:
|
|
return (90, f"~90% ({toner_value:,} pages remaining)")
|
|
return (percentage, f"{percentage}%")
|
|
|
|
# If we can't calculate, estimate based on page count ranges
|
|
if toner_value > 100:
|
|
if toner_value < 500:
|
|
return (10, f"~10% ({toner_value:,} pages remaining)")
|
|
elif toner_value < 2000:
|
|
return (25, f"~25% ({toner_value:,} pages remaining)")
|
|
elif toner_value < 5000:
|
|
return (50, f"~50% ({toner_value:,} pages remaining)")
|
|
elif toner_value < 10000:
|
|
return (75, f"~75% ({toner_value:,} pages remaining)")
|
|
else:
|
|
return (90, f"~90% ({toner_value:,} pages remaining)")
|
|
|
|
return (None, f"{toner_value}")
|
|
|
|
|
|
def get_fqdn_from_ip(ip_address):
|
|
"""Generate FQDN from IP address"""
|
|
# Convert IP like 10.80.92.56 to Printer-10-80-92-56.printer.geaerospace.net
|
|
ip_with_dashes = ip_address.replace('.', '-')
|
|
return f"Printer-{ip_with_dashes}.printer.geaerospace.net"
|
|
|
|
|
|
def normalize_model_name(model_name):
|
|
"""Normalize model name by removing firmware/system version info"""
|
|
# Remove common firmware/version patterns
|
|
# Examples:
|
|
# "Xerox VersaLink C7125 Color MFP; System 69.21.21, Controller..." -> "Xerox VersaLink C7125"
|
|
# "HP LaserJet Pro M404n" stays the same
|
|
|
|
# Split on semicolon (common separator for firmware info)
|
|
model_clean = model_name.split(';')[0].strip()
|
|
|
|
# Remove common version patterns in parentheses
|
|
import re
|
|
model_clean = re.sub(r'\s*\([^)]*\)', '', model_clean)
|
|
|
|
# Remove trailing descriptors that are too specific
|
|
# Keep: "Color MFP", "PostScript", etc.
|
|
# But remove system versions, dates, etc.
|
|
|
|
return model_clean.strip()
|
|
|
|
|
|
def normalize_supply_description(description):
|
|
"""Normalize supply description by removing serial numbers and other unique identifiers"""
|
|
import re
|
|
|
|
# Remove serial numbers (e.g., ";SN804A5C260...", ";SNunknown")
|
|
# Match ;SN followed by anything (letters, numbers, "unknown", etc.)
|
|
desc_clean = re.sub(r';SN\w+.*$', '', description)
|
|
|
|
# Remove trailing dots/ellipsis
|
|
desc_clean = desc_clean.rstrip('.')
|
|
|
|
# Clean up extra whitespace
|
|
desc_clean = ' '.join(desc_clean.split())
|
|
|
|
return desc_clean.strip()
|
|
|
|
|
|
def fix_unknown_part_numbers(description, model):
|
|
"""
|
|
Replace 'PN unknown' with known part numbers based on printer model
|
|
"""
|
|
# Normalize model name for matching
|
|
normalized_model = normalize_model_name(model)
|
|
|
|
# Check if this model has known part number mappings
|
|
if normalized_model not in XEROX_PART_NUMBER_MAP:
|
|
return description
|
|
|
|
# Extract the supply type from description (e.g., "Fuser, PN unknown" -> "Fuser")
|
|
supply_type = description.split(',')[0].strip()
|
|
|
|
# Check if we have a mapping for this supply type
|
|
part_map = XEROX_PART_NUMBER_MAP[normalized_model]
|
|
if supply_type in part_map:
|
|
# Replace "PN unknown" with the actual part number
|
|
known_pn = part_map[supply_type]
|
|
return description.replace('PN unknown', f'PN {known_pn}')
|
|
|
|
return description
|
|
|
|
|
|
# Known part numbers for Xerox models that report generic descriptions
|
|
XEROX_PART_NUMBER_MAP = {
|
|
'Xerox VersaLink C7125 Color MFP': {
|
|
'Black Toner Cartridge': '006R01820',
|
|
'Cyan Toner Cartridge': '006R01821',
|
|
'Magenta Toner Cartridge': '006R01822',
|
|
'Yellow Toner Cartridge': '006R01823',
|
|
},
|
|
'Xerox VersaLink C405': {
|
|
'Black Toner Cartridge': '106R03536',
|
|
'Cyan Toner Cartridge': '106R03538',
|
|
'Magenta Toner Cartridge': '106R03539',
|
|
'Yellow Toner Cartridge': '106R03537',
|
|
},
|
|
'Xerox VersaLink B7125 MFP': {
|
|
'Toner Cartridge': '006R01817',
|
|
},
|
|
'Xerox VersaLink B405': {
|
|
'Toner Cartridge': '106R03580', # Standard capacity (5.9K pages)
|
|
},
|
|
'Xerox VersaLink B400': {
|
|
'Toner Cartridge': '106R03580', # Standard capacity (5.9K pages)
|
|
},
|
|
'Xerox EC8036 Color': {
|
|
'Fuser': '008R13063', # 110V
|
|
'Second Bias Transfer Roll': '008R13064',
|
|
},
|
|
'Xerox AltaLink C8135': {
|
|
'Fuser': '008R13102', # 110V
|
|
'Second Bias Transfer Roll': '008R08103',
|
|
},
|
|
}
|
|
|
|
# Printer supply compatibility groups based on research
|
|
COMPATIBILITY_GROUPS = [
|
|
{
|
|
'priority': 'High',
|
|
'name': 'HP LaserJet Pro M404n + M406 Consolidation',
|
|
'models': ['HP LaserJet Pro M404n', 'HP LaserJet Pro M406'],
|
|
'shared_supplies': [
|
|
{'type': 'Toner', 'parts': ['CF258A (58A Standard)', 'CF258X (58X High Yield)']},
|
|
{'type': 'Fuser', 'parts': ['RM2-6461 / RM2-6342']},
|
|
],
|
|
'benefit': 'Both models share identical toner and fuser - consolidate to single SKUs'
|
|
},
|
|
{
|
|
'priority': 'High',
|
|
'name': 'HP DesignJet Plotter Ink Sharing',
|
|
'models': ['HP DesignJet T1700dr', 'HP DesignJet T1600', 'HP DesignJet T2600'],
|
|
'shared_supplies': [
|
|
{'type': 'Ink Cartridges', 'parts': [
|
|
'HP 730 series (P2V62A-P2V72A)',
|
|
'All colors: Matte Black, Photo Black, Cyan, Magenta, Yellow, Gray'
|
|
]},
|
|
],
|
|
'benefit': 'T1700dr shares all HP 730 inks with T1600/T2600 series'
|
|
},
|
|
{
|
|
'priority': 'Medium',
|
|
'name': 'HP LaserJet 90A/X Family',
|
|
'models': ['HP LaserJet M4555', 'HP LaserJet M602', 'HP LaserJet M603'],
|
|
'shared_supplies': [
|
|
{'type': 'Toner', 'parts': ['CE390A (90A Standard)', 'CE390X (90X High Yield)']},
|
|
],
|
|
'benefit': 'All use CE390A/X toner cartridges'
|
|
},
|
|
{
|
|
'priority': 'Medium',
|
|
'name': 'HP LaserJet 42A/X Family',
|
|
'models': ['HP LaserJet 4250', 'HP LaserJet 4350'],
|
|
'shared_supplies': [
|
|
{'type': 'Toner', 'parts': ['Q5942A (42A Standard)', 'Q5942X (42X High Yield)']},
|
|
],
|
|
'benefit': 'Compatible toner across 4250/4350 models'
|
|
},
|
|
{
|
|
'priority': 'Medium',
|
|
'name': 'HP LaserJet 55X Family',
|
|
'models': ['HP LaserJet P3015', 'HP LaserJet P3010'],
|
|
'shared_supplies': [
|
|
{'type': 'Toner', 'parts': ['CE255X (55X High Yield)']},
|
|
],
|
|
'benefit': 'Share CE255X high-yield toner cartridges'
|
|
},
|
|
{
|
|
'priority': 'Medium',
|
|
'name': 'Xerox VersaLink C405/C400 Series',
|
|
'models': ['Xerox VersaLink C405', 'Xerox VersaLink C400'],
|
|
'shared_supplies': [
|
|
{'type': 'Toner', 'parts': [
|
|
'106R03536 (Black)',
|
|
'106R03538 (Cyan)',
|
|
'106R03539 (Magenta)',
|
|
'106R03537 (Yellow)'
|
|
]},
|
|
],
|
|
'benefit': 'C405 and C400 share identical 106R035xx toner series'
|
|
},
|
|
{
|
|
'priority': 'Medium',
|
|
'name': 'Xerox VersaLink B405/B400 Series',
|
|
'models': ['Xerox VersaLink B405', 'Xerox VersaLink B400'],
|
|
'shared_supplies': [
|
|
{'type': 'Toner', 'parts': [
|
|
'106R03580 (Standard Yield)',
|
|
'106R03581 (High Yield)',
|
|
'106R03582 (Extra High Yield)'
|
|
]},
|
|
],
|
|
'benefit': 'B405 and B400 share 106R035xx toner family'
|
|
},
|
|
{
|
|
'priority': 'Critical',
|
|
'name': 'HP LaserJet Pro M254dw Toner Correction',
|
|
'models': ['HP LaserJet Pro M254dw'],
|
|
'shared_supplies': [
|
|
{'type': 'Toner', 'parts': [
|
|
'CF500A (202A Black)',
|
|
'CF501A (202A Cyan)',
|
|
'CF502A (202A Magenta)',
|
|
'CF503A (202A Yellow)'
|
|
]},
|
|
],
|
|
'benefit': 'IMPORTANT: Uses CF500A (202A) series, NOT W2020A (414A). Verify existing stock!'
|
|
},
|
|
]
|
|
|
|
|
|
def replace_generic_xerox_pn(description, model_name):
|
|
"""Replace generic Xerox part numbers with known specific ones based on model"""
|
|
# Check if we have a mapping for this model
|
|
if model_name not in XEROX_PART_NUMBER_MAP:
|
|
return description
|
|
|
|
# Find which cartridge type this is
|
|
for cartridge_type, part_number in XEROX_PART_NUMBER_MAP[model_name].items():
|
|
if cartridge_type in description:
|
|
# Handle "PN Genuine Xerox(R) Toner" replacement
|
|
if 'PN Genuine Xerox(R) Toner' in description:
|
|
return description.replace('PN Genuine Xerox(R) Toner', f'PN {part_number}')
|
|
# Handle missing part number (just "Toner Cartridge" with serial)
|
|
elif ';SN' in description and 'PN' not in description:
|
|
# Insert part number before serial number
|
|
return description.replace(';SN', f', PN {part_number};SN')
|
|
# Handle simple "Toner Cartridge" without serial
|
|
elif description == cartridge_type:
|
|
return f'{cartridge_type}, PN {part_number}'
|
|
|
|
return description
|
|
|
|
|
|
def filter_generic_supplies(supplies_list):
|
|
"""Remove generic Xerox entries when specific part numbers exist"""
|
|
# Convert to list if it's a set
|
|
supplies = list(supplies_list)
|
|
|
|
# Group by color/type (Black, Cyan, Magenta, Yellow)
|
|
# If we have both a specific PN and "Genuine Xerox(R) Toner" for the same color, remove the generic one
|
|
filtered = []
|
|
|
|
for supply in supplies:
|
|
# Skip generic Xerox entries if we have a real part number for this color
|
|
if 'PN Genuine Xerox(R) Toner' in supply:
|
|
# Extract the color (Black, Cyan, Magenta, Yellow)
|
|
color = None
|
|
for c in ['Black', 'Cyan', 'Magenta', 'Yellow']:
|
|
if c in supply:
|
|
color = c
|
|
break
|
|
|
|
# Check if we have a real part number for this color
|
|
if color:
|
|
has_real_pn = any(
|
|
color in s and 'PN Genuine Xerox(R) Toner' not in s and 'PN 006R' in s
|
|
for s in supplies
|
|
)
|
|
if has_real_pn:
|
|
continue # Skip this generic entry
|
|
|
|
filtered.append(supply)
|
|
|
|
return filtered
|
|
|
|
|
|
def extract_key_data(csv_file):
|
|
"""Extract all key printer information from CSV"""
|
|
data = {
|
|
'model': 'Unknown',
|
|
'hostname': 'Unknown',
|
|
'serial': 'Unknown',
|
|
'supplies': {},
|
|
'toner_levels': {},
|
|
'toner_percentages': {},
|
|
'maintenance_kit': {},
|
|
'network': {},
|
|
'is_color': False
|
|
}
|
|
|
|
if not os.path.exists(csv_file):
|
|
return data
|
|
|
|
with open(csv_file, 'r', encoding='utf-8') as f:
|
|
reader = csv.DictReader(f)
|
|
|
|
for row in reader:
|
|
oid = row['OID']
|
|
value = clean_value(row['Value'])
|
|
|
|
# System Description
|
|
if oid == '1.3.6.1.2.1.1.1.0':
|
|
data['model'] = value
|
|
|
|
# Device Model (more specific)
|
|
elif oid == '1.3.6.1.2.1.25.3.2.1.3.1':
|
|
if data['model'] == 'Unknown' or 'ETHERNET' in data['model']:
|
|
data['model'] = value
|
|
|
|
# Hostname
|
|
elif oid == '1.3.6.1.2.1.1.5.0':
|
|
data['hostname'] = value
|
|
|
|
# Serial Number (standard OID)
|
|
elif '1.3.6.1.2.1.43.5.1.1.17' in oid:
|
|
data['serial'] = value
|
|
|
|
# Serial Number (HP-specific OID for DesignJet and other HP printers)
|
|
elif '1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.3.0' in oid:
|
|
# Only use if we don't already have a serial from standard OID
|
|
if data['serial'] == 'Unknown':
|
|
data['serial'] = value
|
|
|
|
# Maintenance Kit Info (HP) - exact OID match only to avoid false matches
|
|
elif oid == '1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.3.0':
|
|
# Model/Serial
|
|
if value and value not in ['0', '', 'N/A']:
|
|
data['maintenance_kit']['model'] = clean_value(value)
|
|
elif oid == '1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.2.0':
|
|
# Printer Model
|
|
if value and value not in ['0', '', 'N/A']:
|
|
data['maintenance_kit']['printer_model'] = clean_value(value)
|
|
elif oid == '1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.2.0':
|
|
# Maintenance kit remaining pages
|
|
if value and value not in ['0', '', 'N/A', '-1', '-2', '-3']:
|
|
try:
|
|
data['maintenance_kit']['remaining'] = int(value)
|
|
except:
|
|
pass
|
|
elif oid == '1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.1.0':
|
|
# Part Number
|
|
if value and value not in ['0', '', 'N/A']:
|
|
data['maintenance_kit']['part_number'] = clean_value(value)
|
|
|
|
# Standard Printer MIB - Maintenance Kit (Index 2)
|
|
# Only for HP printers - Xerox uses index 2 for toner cartridges
|
|
elif oid == '1.3.6.1.2.1.43.11.1.1.6.1.2':
|
|
# Part Number (Standard MIB)
|
|
if value and value not in ['0', '', 'N/A']:
|
|
# Check if this looks like a maintenance kit (not toner)
|
|
value_lower = value.lower()
|
|
is_maintenance = 'maintenance kit' in value_lower or 'maintenance-kit' in value_lower
|
|
# Store the standard MIB part description to verify this is actually a maintenance kit
|
|
if is_maintenance:
|
|
data['maintenance_kit']['standard_part_desc'] = value
|
|
# Only set part_number if we don't already have one from HP proprietary MIB
|
|
if 'part_number' not in data['maintenance_kit']:
|
|
data['maintenance_kit']['part_number'] = clean_value(value)
|
|
elif oid == '1.3.6.1.2.1.43.11.1.1.9.1.2':
|
|
# Current level (Standard MIB)
|
|
# Only collect if we already identified this as a maintenance kit (not toner)
|
|
if value and value not in ['0', '', 'N/A', '-1', '-2', '-3']:
|
|
# Only store if we have a maintenance kit part_number already set from this index
|
|
# This prevents storing toner levels as maintenance kit levels
|
|
try:
|
|
# Store temporarily, will validate later
|
|
data['maintenance_kit']['standard_current_temp'] = int(value)
|
|
except:
|
|
pass
|
|
elif oid == '1.3.6.1.2.1.43.11.1.1.8.1.2':
|
|
# Max capacity (Standard MIB)
|
|
if value and value not in ['0', '', 'N/A', '-1', '-2', '-3']:
|
|
try:
|
|
# Store temporarily, will validate later
|
|
data['maintenance_kit']['standard_max_temp'] = int(value)
|
|
except:
|
|
pass
|
|
|
|
# Network location
|
|
elif oid == '1.3.6.1.2.1.1.6.0':
|
|
data['network']['location'] = value
|
|
|
|
# Extract toner information
|
|
supplies, toner_levels_raw, toner_max_capacity, supply_current, supply_max, hp_designjet_levels = extract_toner_info(csv_file)
|
|
data['supplies'] = supplies
|
|
|
|
# Detect large format printers (DesignJet, Plotter series) early to determine which OIDs to use
|
|
model_lower = data['model'].lower() if data['model'] else ''
|
|
data['is_large_format'] = any(keyword in model_lower for keyword in ['designjet', 'plotter', 't1700', 't1600', 't2600'])
|
|
|
|
# Fix Xerox VersaLink color mapping issue
|
|
# Some Xerox VersaLink printers report cyan/yellow descriptions AND levels swapped at indices 2 and 4
|
|
# Affects: C7125, C405, C400, and potentially other VersaLink color models
|
|
versalink_swap_needed = False
|
|
if 'versalink' in model_lower and ('c7125' in model_lower or 'c405' in model_lower or 'c400' in model_lower):
|
|
if '2' in supplies and '4' in supplies:
|
|
desc2 = supplies['2'].get('description', '').lower()
|
|
desc4 = supplies['4'].get('description', '').lower()
|
|
# If index 2 says "yellow" and index 4 says "cyan", they need to be swapped
|
|
if 'yellow' in desc2 and 'cyan' in desc4:
|
|
versalink_swap_needed = True
|
|
supplies['2'], supplies['4'] = supplies['4'], supplies['2']
|
|
|
|
# Finalize Standard MIB maintenance kit data
|
|
# Only promote temp values if we verified index 2 is actually a maintenance kit
|
|
if 'standard_part_desc' in data['maintenance_kit']:
|
|
# We found "Maintenance Kit" at index 2, so the temp values are valid
|
|
if 'standard_current_temp' in data['maintenance_kit']:
|
|
data['maintenance_kit']['standard_current'] = data['maintenance_kit'].pop('standard_current_temp')
|
|
if 'standard_max_temp' in data['maintenance_kit']:
|
|
data['maintenance_kit']['standard_max'] = data['maintenance_kit'].pop('standard_max_temp')
|
|
# Clean up the verification field
|
|
data['maintenance_kit'].pop('standard_part_desc', None)
|
|
else:
|
|
# Index 2 is not a maintenance kit (probably toner) - discard temp values
|
|
data['maintenance_kit'].pop('standard_current_temp', None)
|
|
data['maintenance_kit'].pop('standard_max_temp', None)
|
|
|
|
# If HP DesignJet with proprietary ink level OIDs, use those instead of standard OIDs
|
|
if hp_designjet_levels and data['is_large_format']:
|
|
# HP DesignJet provides direct percentages via proprietary OID
|
|
for idx, percentage in hp_designjet_levels.items():
|
|
data['toner_levels'][idx] = f"{percentage}%"
|
|
data['toner_percentages'][idx] = percentage
|
|
else:
|
|
# Standard printer: Calculate percentages from standard OIDs
|
|
for idx, raw_value in toner_levels_raw.items():
|
|
percentage, display = calculate_toner_percentage(idx, raw_value, toner_max_capacity, supply_current, supply_max, data['model'])
|
|
data['toner_levels'][idx] = display
|
|
data['toner_percentages'][idx] = percentage
|
|
|
|
# Fix Xerox VersaLink toner level data swap (must happen AFTER percentage calculation)
|
|
# These printers report cyan/yellow LEVELS at swapped indices (not just descriptions)
|
|
if versalink_swap_needed:
|
|
# Swap the calculated toner levels and percentages
|
|
if '2' in data['toner_levels'] and '4' in data['toner_levels']:
|
|
data['toner_levels']['2'], data['toner_levels']['4'] = data['toner_levels']['4'], data['toner_levels']['2']
|
|
if '2' in data['toner_percentages'] and '4' in data['toner_percentages']:
|
|
data['toner_percentages']['2'], data['toner_percentages']['4'] = data['toner_percentages']['4'], data['toner_percentages']['2']
|
|
|
|
# Determine if printer is color (check for cyan/magenta/yellow indices)
|
|
# Indices: 1=Black/Matte Black, 2=Cyan, 3=Magenta, 4=Yellow, 5=Photo Black, 6=Gray
|
|
# Check both standard and HP DesignJet OIDs
|
|
all_indices = set(toner_levels_raw.keys()) | set(hp_designjet_levels.keys())
|
|
data['is_color'] = any(idx in all_indices for idx in ['2', '3', '4', '5', '6'])
|
|
|
|
return data
|
|
|
|
|
|
def print_printer_summary(printer_ip, csv_file):
|
|
"""Print formatted summary for a single printer"""
|
|
data = extract_key_data(csv_file)
|
|
|
|
print(f"\n{'='*70}")
|
|
print(f"Printer: {printer_ip}")
|
|
print(f"{'='*70}")
|
|
print(f"Model: {data['model']}")
|
|
print(f"Hostname: {data['hostname']}")
|
|
print(f"Serial: {data['serial']}")
|
|
print(f"Type: {'Color' if data['is_color'] else 'Monochrome'}")
|
|
|
|
# Toner levels
|
|
if data['toner_levels']:
|
|
# Use different label for DesignJet (ink) vs standard printers (toner)
|
|
supply_type = "Ink Levels" if data.get('is_large_format', False) else "Toner Levels"
|
|
print(f"\n {supply_type}:")
|
|
|
|
# For HP DesignJets with HP 730 cartridges, map by part number instead of index
|
|
# Build index-to-color mapping dynamically
|
|
idx_to_color = {}
|
|
|
|
if data.get('is_large_format', False):
|
|
# Try to map by HP 730 part numbers
|
|
for idx in data['toner_levels'].keys():
|
|
if idx in data.get('supplies', {}):
|
|
desc = data['supplies'][idx].get('description', '')
|
|
color = map_hp730_color(desc)
|
|
if color:
|
|
idx_to_color[idx] = color
|
|
|
|
# If we couldn't map by part number, fall back to generic index mapping
|
|
if not idx_to_color:
|
|
idx_to_color = {
|
|
'1': 'Cartridge 1',
|
|
'2': 'Cartridge 2',
|
|
'3': 'Cartridge 3',
|
|
'4': 'Cartridge 4',
|
|
'5': 'Cartridge 5',
|
|
'6': 'Cartridge 6'
|
|
}
|
|
else:
|
|
# Standard 4-color printers
|
|
idx_to_color = {
|
|
'1': 'Black',
|
|
'2': 'Cyan',
|
|
'3': 'Magenta',
|
|
'4': 'Yellow'
|
|
}
|
|
|
|
# Define display order for colors
|
|
color_order = ['Matte Black', 'Photo Black', 'Cyan', 'Magenta', 'Yellow', 'Gray', 'Black']
|
|
|
|
# Sort indices by color order
|
|
sorted_indices = sorted(
|
|
[idx for idx in data['toner_levels'].keys() if idx in idx_to_color],
|
|
key=lambda x: color_order.index(idx_to_color[x]) if idx_to_color[x] in color_order else 999
|
|
)
|
|
|
|
for idx in sorted_indices:
|
|
color = idx_to_color.get(idx, f'Index {idx}')
|
|
|
|
# Check if this index actually contains toner/cartridge, not maintenance kit
|
|
if idx in data['supplies']:
|
|
desc = data['supplies'][idx].get('description', '')
|
|
desc_lower = desc.lower()
|
|
# Skip if it's clearly not a toner cartridge
|
|
if any(keyword in desc_lower for keyword in ['maintenance kit', 'fuser', 'transfer', 'drum cartridge kit']):
|
|
continue
|
|
|
|
display = data['toner_levels'][idx]
|
|
print(f" {color}: {display}")
|
|
|
|
# Show cartridge info if available
|
|
if idx in data['supplies']:
|
|
desc = data['supplies'][idx].get('description', '')
|
|
if desc:
|
|
if len(desc) > 60:
|
|
desc = desc[:60] + "..."
|
|
print(f" Cartridge: {desc}")
|
|
|
|
# Other supplies (drums, fuser, waste)
|
|
if data['supplies']:
|
|
other_supplies = []
|
|
for idx, supply in data['supplies'].items():
|
|
# Skip main toners (1-4) or all 6 colors for DesignJet (1-6)
|
|
if data.get('is_large_format', False):
|
|
if idx in ['1', '2', '3', '4', '5', '6']:
|
|
continue
|
|
else:
|
|
if idx in ['1', '2', '3', '4']:
|
|
continue
|
|
|
|
desc = supply.get('description', '')
|
|
if desc:
|
|
# Check if it's a maintenance-related item
|
|
keywords = ['fuser', 'drum', 'waste', 'transfer', 'belt', 'roller', 'kit']
|
|
if any(keyword in desc.lower() for keyword in keywords):
|
|
# Fix unknown part numbers with known ones based on model
|
|
desc = fix_unknown_part_numbers(desc, data['model'])
|
|
other_supplies.append((idx, desc))
|
|
|
|
if other_supplies:
|
|
print(f"\n Other Supplies (Fuser, Drums, etc.):")
|
|
for idx, desc in sorted(other_supplies, key=lambda x: x[1]):
|
|
if len(desc) > 70:
|
|
desc = desc[:70] + "..."
|
|
print(f" {desc}")
|
|
|
|
# Maintenance kit (HP specific)
|
|
if data['maintenance_kit']:
|
|
print(f"\n Maintenance Kit:")
|
|
for key, value in data['maintenance_kit'].items():
|
|
if value and value not in ['N/A', '0', '']:
|
|
label = key.replace('_', ' ').title()
|
|
print(f" {label}: {value}")
|
|
|
|
# Network info
|
|
if data['network'] and data['network'].get('location'):
|
|
print(f"\n Network:")
|
|
print(f" Location: {data['network']['location']}")
|
|
|
|
|
|
def print_models_with_supplies(models_data):
|
|
"""Print a breakdown of printer models with their replacement hardware"""
|
|
print(f"\n{'='*70}")
|
|
print("PRINTER MODELS AND REPLACEMENT HARDWARE")
|
|
print(f"{'='*70}\n")
|
|
|
|
# Dictionary to track which models use which supplies
|
|
supply_to_models = {}
|
|
|
|
for model_name in sorted(models_data.keys()):
|
|
supplies = models_data[model_name]
|
|
print(f"\nModel: {model_name}")
|
|
print("-" * 70)
|
|
|
|
# Categorize supplies
|
|
toner_cartridges = []
|
|
drums = []
|
|
fusers = []
|
|
waste_toners = []
|
|
transfer_items = []
|
|
maintenance_kits = []
|
|
other_items = []
|
|
|
|
# Filter out generic Xerox entries when we have specific PNs
|
|
filtered_supplies = filter_generic_supplies(set(supplies))
|
|
|
|
for supply_desc in sorted(filtered_supplies):
|
|
supply_lower = supply_desc.lower()
|
|
|
|
# Track which models use this supply
|
|
if supply_desc not in supply_to_models:
|
|
supply_to_models[supply_desc] = []
|
|
if model_name not in supply_to_models[supply_desc]:
|
|
supply_to_models[supply_desc].append(model_name)
|
|
|
|
if 'toner' in supply_lower and 'waste' not in supply_lower:
|
|
toner_cartridges.append(supply_desc)
|
|
elif 'drum' in supply_lower:
|
|
drums.append(supply_desc)
|
|
elif 'fuser' in supply_lower:
|
|
fusers.append(supply_desc)
|
|
elif 'waste' in supply_lower:
|
|
waste_toners.append(supply_desc)
|
|
elif 'transfer' in supply_lower or 'belt' in supply_lower:
|
|
transfer_items.append(supply_desc)
|
|
elif 'maintenance' in supply_lower or 'kit' in supply_lower:
|
|
maintenance_kits.append(supply_desc)
|
|
else:
|
|
other_items.append(supply_desc)
|
|
|
|
# Print categorized items
|
|
if toner_cartridges:
|
|
print(" Toner Cartridges:")
|
|
for item in sorted(toner_cartridges):
|
|
print(f" - {item}")
|
|
|
|
if drums:
|
|
print(" Drums:")
|
|
for item in sorted(drums):
|
|
print(f" - {item}")
|
|
|
|
if fusers:
|
|
print(" Fusers:")
|
|
for item in sorted(fusers):
|
|
print(f" - {item}")
|
|
|
|
if waste_toners:
|
|
print(" Waste Toner:")
|
|
for item in sorted(waste_toners):
|
|
print(f" - {item}")
|
|
|
|
if transfer_items:
|
|
print(" Transfer/Belt:")
|
|
for item in sorted(transfer_items):
|
|
print(f" - {item}")
|
|
|
|
if maintenance_kits:
|
|
print(" Maintenance Kits:")
|
|
for item in sorted(maintenance_kits):
|
|
print(f" - {item}")
|
|
|
|
if other_items:
|
|
print(" Other:")
|
|
for item in sorted(other_items):
|
|
print(f" - {item}")
|
|
|
|
# Print consolidated summary
|
|
print(f"\n{'='*70}")
|
|
print("CONSOLIDATED SUPPLY SUMMARY")
|
|
print(f"{'='*70}\n")
|
|
print("Supplies grouped by part (showing which models use each):\n")
|
|
|
|
# Categorize all supplies globally
|
|
categories = {
|
|
'Toner Cartridges': [],
|
|
'Drums': [],
|
|
'Fusers': [],
|
|
'Waste Toner': [],
|
|
'Transfer/Belt': [],
|
|
'Maintenance Kits': [],
|
|
'Other': []
|
|
}
|
|
|
|
for supply_desc, model_list in supply_to_models.items():
|
|
supply_lower = supply_desc.lower()
|
|
|
|
if 'toner' in supply_lower and 'waste' not in supply_lower:
|
|
categories['Toner Cartridges'].append((supply_desc, model_list))
|
|
elif 'drum' in supply_lower:
|
|
categories['Drums'].append((supply_desc, model_list))
|
|
elif 'fuser' in supply_lower:
|
|
categories['Fusers'].append((supply_desc, model_list))
|
|
elif 'waste' in supply_lower:
|
|
categories['Waste Toner'].append((supply_desc, model_list))
|
|
elif 'transfer' in supply_lower or 'belt' in supply_lower:
|
|
categories['Transfer/Belt'].append((supply_desc, model_list))
|
|
elif 'maintenance' in supply_lower or 'kit' in supply_lower:
|
|
categories['Maintenance Kits'].append((supply_desc, model_list))
|
|
else:
|
|
categories['Other'].append((supply_desc, model_list))
|
|
|
|
# Print each category
|
|
for category_name, items in categories.items():
|
|
if items:
|
|
print(f"{category_name}:")
|
|
for supply_desc, model_list in sorted(items):
|
|
# Show models using this supply
|
|
if len(model_list) > 1:
|
|
model_str = f" (used by {len(model_list)} models)"
|
|
else:
|
|
model_str = f" (used by {model_list[0]})"
|
|
print(f" - {supply_desc}{model_str}")
|
|
print()
|
|
|
|
print(f"{'='*70}\n")
|
|
|
|
|
|
def generate_html_report(models_data, all_printers_data):
|
|
"""Generate a professional HTML report"""
|
|
|
|
# Track supplies that need replacement (below 10% or not installed)
|
|
low_supplies = []
|
|
|
|
for printer_ip, data in all_printers_data.items():
|
|
# Generate FQDN for this printer
|
|
fqdn = get_fqdn_from_ip(printer_ip)
|
|
|
|
# Check toner levels - map by part number for DesignJets, use index for standard printers
|
|
idx_to_color = {}
|
|
if data.get('is_large_format', False):
|
|
# Try to map by HP 730 part numbers
|
|
for idx in data.get('toner_levels', {}).keys():
|
|
if idx in data.get('supplies', {}):
|
|
desc = data['supplies'][idx].get('description', '')
|
|
color = map_hp730_color(desc)
|
|
if color:
|
|
idx_to_color[idx] = color
|
|
# Fallback if mapping failed
|
|
if not idx_to_color:
|
|
idx_to_color = {'1': 'Cartridge 1', '2': 'Cartridge 2', '3': 'Cartridge 3', '4': 'Cartridge 4', '5': 'Cartridge 5', '6': 'Cartridge 6'}
|
|
else:
|
|
idx_to_color = {'1': 'Black', '2': 'Cyan', '3': 'Magenta', '4': 'Yellow'}
|
|
|
|
for idx, color_name in idx_to_color.items():
|
|
# Check if this toner exists in the data
|
|
if idx in data.get('toner_levels', {}):
|
|
display = data['toner_levels'][idx]
|
|
percentage = data.get('toner_percentages', {}).get(idx)
|
|
|
|
# Get the cartridge description
|
|
cartridge_desc = "Unknown Cartridge"
|
|
if idx in data.get('supplies', {}):
|
|
desc = data['supplies'][idx].get('description', '')
|
|
desc_lower = desc.lower()
|
|
# Skip if it's not a toner cartridge
|
|
if any(keyword in desc_lower for keyword in ['maintenance kit', 'fuser', 'transfer', 'drum cartridge kit']):
|
|
continue
|
|
# Normalize the cartridge description to remove serial numbers
|
|
cartridge_desc = normalize_supply_description(desc)
|
|
|
|
# Add to low supplies if percentage <= 10% or not installed
|
|
if percentage is not None and percentage <= 10:
|
|
low_supplies.append({
|
|
'printer_ip': printer_ip,
|
|
'fqdn': fqdn,
|
|
'hostname': data.get('hostname', 'Unknown'),
|
|
'model': data.get('normalized_model', data.get('model', 'Unknown')),
|
|
'color': color_name,
|
|
'percentage': percentage,
|
|
'cartridge': cartridge_desc,
|
|
'status': 'LOW'
|
|
})
|
|
elif 'Not installed' in display or 'not installed' in display.lower():
|
|
low_supplies.append({
|
|
'printer_ip': printer_ip,
|
|
'fqdn': fqdn,
|
|
'hostname': data.get('hostname', 'Unknown'),
|
|
'model': data.get('normalized_model', data.get('model', 'Unknown')),
|
|
'color': color_name,
|
|
'percentage': 0,
|
|
'cartridge': cartridge_desc,
|
|
'status': 'MISSING'
|
|
})
|
|
|
|
# Build supply to models mapping
|
|
supply_to_models = {}
|
|
for model_name, supplies in models_data.items():
|
|
for supply_desc in set(supplies):
|
|
if supply_desc not in supply_to_models:
|
|
supply_to_models[supply_desc] = []
|
|
if model_name not in supply_to_models[supply_desc]:
|
|
supply_to_models[supply_desc].append(model_name)
|
|
|
|
# Categorize supplies for consolidated summary
|
|
categories = {
|
|
'Toner Cartridges': [],
|
|
'Drums': [],
|
|
'Fusers': [],
|
|
'Waste Toner': [],
|
|
'Transfer/Belt': [],
|
|
'Maintenance Kits': [],
|
|
'Other': []
|
|
}
|
|
|
|
for supply_desc, model_list in supply_to_models.items():
|
|
supply_lower = supply_desc.lower()
|
|
if 'toner' in supply_lower and 'waste' not in supply_lower:
|
|
categories['Toner Cartridges'].append((supply_desc, model_list))
|
|
elif 'drum' in supply_lower:
|
|
categories['Drums'].append((supply_desc, model_list))
|
|
elif 'fuser' in supply_lower:
|
|
categories['Fusers'].append((supply_desc, model_list))
|
|
elif 'waste' in supply_lower:
|
|
categories['Waste Toner'].append((supply_desc, model_list))
|
|
elif 'transfer' in supply_lower or 'belt' in supply_lower:
|
|
categories['Transfer/Belt'].append((supply_desc, model_list))
|
|
elif 'maintenance' in supply_lower or 'kit' in supply_lower:
|
|
categories['Maintenance Kits'].append((supply_desc, model_list))
|
|
else:
|
|
categories['Other'].append((supply_desc, model_list))
|
|
|
|
# Read favicon and convert to base64
|
|
import base64
|
|
try:
|
|
with open('output/favicon.ico', 'rb') as f:
|
|
favicon_base64 = base64.b64encode(f.read()).decode('utf-8')
|
|
favicon_data_uri = f"data:image/x-icon;base64,{favicon_base64}"
|
|
except:
|
|
favicon_data_uri = ""
|
|
|
|
# Embedded SVG logos
|
|
logo_svg = '''<svg width="138" height="32" viewBox="0 0 138 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<mask id="mask0_light" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="138" height="32">
|
|
<path d="M138 0H0V32H138V0Z" fill="white"/>
|
|
</mask>
|
|
<g mask="url(#mask0_light)">
|
|
<path d="M52.7203 22.4828V9.51721H59.3399V10.944H54.3731V15.2042H59.0216V16.5948H54.3731V21.06H59.3399V22.4868H52.7203V22.4828ZM66.7717 17.1294L68.5021 10.9962L70.2325 17.1294H66.7717ZM65.2657 22.4828L66.3921 18.4999H70.6243L71.7507 22.4828H73.4811L69.5673 9.51721H67.4981L63.5843 22.4828H65.2576H65.2657ZM78.2234 14.4607C79.4069 14.4607 79.9742 15.0917 79.9742 16.4421V17.2017H76.4766V16.4421C76.4766 15.0877 77.0398 14.4607 78.2275 14.4607M74.8605 16.5546V19.3358C74.8605 21.5021 76.044 22.6677 78.1907 22.6677C80.3374 22.6677 81.4638 21.7232 81.5373 19.7217H79.9742C79.9171 20.9234 79.3743 21.4258 78.1867 21.4258C76.999 21.4258 76.4766 20.7787 76.4766 19.4243V18.4999H81.574V16.5185C81.574 14.3682 80.4068 13.2228 78.2275 13.2228C76.0481 13.2228 74.8605 14.3883 74.8605 16.5546ZM87.8957 14.9269V13.3353H87.7243C86.5775 13.3353 85.7858 13.8337 85.3368 14.7822V13.4117H83.8146V22.4868H85.4307V16.9847C85.4307 15.783 86.0143 14.8907 87.5162 14.8907C87.6672 14.8907 87.8182 14.9068 87.8916 14.9269M92.3319 21.4097C91.1484 21.4097 90.6015 20.7787 90.6015 19.4283V16.4622C90.6015 15.1118 91.1484 14.4808 92.3319 14.4808C93.5154 14.4808 94.0623 15.1118 94.0623 16.4622V19.4283C94.0623 20.7787 93.5154 21.4097 92.3319 21.4097ZM92.3319 22.6677C94.5153 22.6677 95.6825 21.5021 95.6825 19.3358V16.5546C95.6825 14.3883 94.5153 13.2228 92.3319 13.2228C90.1485 13.2228 88.9854 14.3883 88.9854 16.5546V19.3358C88.9854 21.5021 90.1526 22.6677 92.3319 22.6677ZM99.1964 15.5378C99.1964 14.8144 99.7025 14.4446 100.588 14.4446C101.641 14.4446 102.074 14.943 102.074 16.0362H103.616C103.616 14.111 102.714 13.2188 100.588 13.2188C98.6863 13.2188 97.6333 14.1432 97.6333 15.7589C97.6333 17.258 98.6128 17.7965 99.8535 18.3873L100.813 18.8495C101.792 19.3117 102.131 19.5328 102.131 20.2763C102.131 21.0721 101.568 21.4418 100.588 21.4418C99.4576 21.4418 98.9719 20.9234 98.9719 19.7739H97.4293C97.4293 21.7553 98.4087 22.6637 100.588 22.6637C102.563 22.6637 103.69 21.6991 103.69 20.0512C103.69 18.5521 102.71 18.0136 101.47 17.4228L100.511 16.9606C99.5311 16.4984 99.1923 16.2773 99.1923 15.5338M110.836 19.4243C110.836 20.7224 110.326 21.3132 109.199 21.3132C107.959 21.3132 107.375 20.4974 107.375 19.1469V16.6471C107.375 15.2967 107.959 14.4808 109.199 14.4808C110.326 14.4808 110.836 15.0756 110.836 16.3698V19.4283V19.4243ZM112.452 19.3881V16.4059C112.452 14.3321 111.379 13.2188 109.554 13.2188C108.501 13.2188 107.73 13.6609 107.277 14.5129V13.4037H105.755V25.8146H107.371V21.4418C107.82 22.1814 108.575 22.5712 109.554 22.5712C111.379 22.5712 112.452 21.4579 112.452 19.3841M117.341 21.3293C116.362 21.3293 115.876 20.8269 115.876 19.8302V19.5529C115.876 18.4959 116.533 18.0899 118.321 18.0899H119.17V19.4243C119.17 20.6662 118.529 21.3333 117.345 21.3333M116.99 22.5752C118.064 22.5752 118.851 22.1492 119.284 21.3333V22.4828H120.79V16.1286C120.79 14.1472 119.757 13.2188 117.59 13.2188C115.541 13.2188 114.525 14.107 114.488 15.9437H116.052C116.072 14.9631 116.598 14.4446 117.594 14.4446C118.668 14.4446 119.174 14.9832 119.174 16.1326V16.836H118.235C115.525 16.836 114.268 17.7242 114.268 19.6534V19.9307C114.268 21.5423 115.341 22.5793 116.994 22.5793M126.34 22.6717C128.54 22.6717 129.536 21.6549 129.536 19.376H127.977C127.977 20.839 127.487 21.4137 126.34 21.4137C125.193 21.4137 124.626 20.7827 124.626 19.4323V16.4662C124.626 15.1158 125.173 14.4848 126.34 14.4848C127.507 14.4848 127.956 14.9832 127.977 16.4099H129.536C129.515 14.1874 128.556 13.2228 126.34 13.2228C124.124 13.2228 123.01 14.3883 123.01 16.5546V19.3358C123.01 21.5021 124.177 22.6677 126.34 22.6677M134.653 14.4607C135.837 14.4607 136.404 15.0917 136.404 16.4421V17.2017H132.907V16.4421C132.907 15.0877 133.47 14.4607 134.658 14.4607M131.291 16.5546V19.3358C131.291 21.5021 132.474 22.6677 134.621 22.6677C136.767 22.6677 137.894 21.7232 137.967 19.7217H136.404C136.347 20.9234 135.804 21.4258 134.617 21.4258C133.429 21.4258 132.907 20.7787 132.907 19.4243V18.4999H138.004V16.5185C138.004 14.3682 136.837 13.2228 134.658 13.2228C132.478 13.2228 131.291 14.3883 131.291 16.5546ZM46.3088 22.6677C47.6433 22.6677 48.8473 22.3904 49.9573 21.8156V15.2042H45.9129V16.5948H48.3208V20.8912C47.7576 21.1324 47.023 21.2811 46.3292 21.2811C44.2968 21.2811 43.5255 20.3165 43.5255 17.8166V13.3514C43.5255 11.7036 44.4682 10.7229 46.0476 10.7229C47.627 10.7229 48.3412 11.4464 48.3779 13.0018H50.092C50.0349 10.5019 48.7575 9.33635 46.0476 9.33635C43.3377 9.33635 41.8726 10.7792 41.8726 13.4117V17.7443C41.8726 21.1726 43.2275 22.6717 46.3129 22.6717M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="#00003d"/>
|
|
</g>
|
|
</svg>'''
|
|
|
|
dark_logo_svg = '''<svg width="138" height="32" viewBox="0 0 138 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<mask id="mask0_dark" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="138" height="32">
|
|
<path d="M138 0H0V32H138V0Z" fill="white"/>
|
|
</mask>
|
|
<g mask="url(#mask0_dark)">
|
|
<path d="M52.7203 22.4828V9.51721H59.3399V10.944H54.3731V15.2042H59.0216V16.5948H54.3731V21.06H59.3399V22.4868H52.7203V22.4828ZM66.7717 17.1294L68.5021 10.9962L70.2325 17.1294H66.7717ZM65.2657 22.4828L66.3921 18.4999H70.6243L71.7507 22.4828H73.4811L69.5673 9.51721H67.4981L63.5843 22.4828H65.2576H65.2657ZM78.2234 14.4607C79.4069 14.4607 79.9742 15.0917 79.9742 16.4421V17.2017H76.4766V16.4421C76.4766 15.0877 77.0398 14.4607 78.2275 14.4607M74.8605 16.5546V19.3358C74.8605 21.5021 76.044 22.6677 78.1907 22.6677C80.3374 22.6677 81.4638 21.7232 81.5373 19.7217H79.9742C79.9171 20.9234 79.3743 21.4258 78.1867 21.4258C76.999 21.4258 76.4766 20.7787 76.4766 19.4243V18.4999H81.574V16.5185C81.574 14.3682 80.4068 13.2228 78.2275 13.2228C76.0481 13.2228 74.8605 14.3883 74.8605 16.5546ZM87.8957 14.9269V13.3353H87.7243C86.5775 13.3353 85.7858 13.8337 85.3368 14.7822V13.4117H83.8146V22.4868H85.4307V16.9847C85.4307 15.783 86.0143 14.8907 87.5162 14.8907C87.6672 14.8907 87.8182 14.9068 87.8916 14.9269M92.3319 21.4097C91.1484 21.4097 90.6015 20.7787 90.6015 19.4283V16.4622C90.6015 15.1118 91.1484 14.4808 92.3319 14.4808C93.5154 14.4808 94.0623 15.1118 94.0623 16.4622V19.4283C94.0623 20.7787 93.5154 21.4097 92.3319 21.4097ZM92.3319 22.6677C94.5153 22.6677 95.6825 21.5021 95.6825 19.3358V16.5546C95.6825 14.3883 94.5153 13.2228 92.3319 13.2228C90.1485 13.2228 88.9854 14.3883 88.9854 16.5546V19.3358C88.9854 21.5021 90.1526 22.6677 92.3319 22.6677ZM99.1964 15.5378C99.1964 14.8144 99.7025 14.4446 100.588 14.4446C101.641 14.4446 102.074 14.943 102.074 16.0362H103.616C103.616 14.111 102.714 13.2188 100.588 13.2188C98.6863 13.2188 97.6333 14.1432 97.6333 15.7589C97.6333 17.258 98.6128 17.7965 99.8535 18.3873L100.813 18.8495C101.792 19.3117 102.131 19.5328 102.131 20.2763C102.131 21.0721 101.568 21.4418 100.588 21.4418C99.4576 21.4418 98.9719 20.9234 98.9719 19.7739H97.4293C97.4293 21.7553 98.4087 22.6637 100.588 22.6637C102.563 22.6637 103.69 21.6991 103.69 20.0512C103.69 18.5521 102.71 18.0136 101.47 17.4228L100.511 16.9606C99.5311 16.4984 99.1923 16.2773 99.1923 15.5338M110.836 19.4243C110.836 20.7224 110.326 21.3132 109.199 21.3132C107.959 21.3132 107.375 20.4974 107.375 19.1469V16.6471C107.375 15.2967 107.959 14.4808 109.199 14.4808C110.326 14.4808 110.836 15.0756 110.836 16.3698V19.4283V19.4243ZM112.452 19.3881V16.4059C112.452 14.3321 111.379 13.2188 109.554 13.2188C108.501 13.2188 107.73 13.6609 107.277 14.5129V13.4037H105.755V25.8146H107.371V21.4418C107.82 22.1814 108.575 22.5712 109.554 22.5712C111.379 22.5712 112.452 21.4579 112.452 19.3841M117.341 21.3293C116.362 21.3293 115.876 20.8269 115.876 19.8302V19.5529C115.876 18.4959 116.533 18.0899 118.321 18.0899H119.17V19.4243C119.17 20.6662 118.529 21.3333 117.345 21.3333M116.99 22.5752C118.064 22.5752 118.851 22.1492 119.284 21.3333V22.4828H120.79V16.1286C120.79 14.1472 119.757 13.2188 117.59 13.2188C115.541 13.2188 114.525 14.107 114.488 15.9437H116.052C116.072 14.9631 116.598 14.4446 117.594 14.4446C118.668 14.4446 119.174 14.9832 119.174 16.1326V16.836H118.235C115.525 16.836 114.268 17.7242 114.268 19.6534V19.9307C114.268 21.5423 115.341 22.5793 116.994 22.5793M126.34 22.6717C128.54 22.6717 129.536 21.6549 129.536 19.376H127.977C127.977 20.839 127.487 21.4137 126.34 21.4137C125.193 21.4137 124.626 20.7827 124.626 19.4323V16.4662C124.626 15.1158 125.173 14.4848 126.34 14.4848C127.507 14.4848 127.956 14.9832 127.977 16.4099H129.536C129.515 14.1874 128.556 13.2228 126.34 13.2228C124.124 13.2228 123.01 14.3883 123.01 16.5546V19.3358C123.01 21.5021 124.177 22.6677 126.34 22.6677M134.653 14.4607C135.837 14.4607 136.404 15.0917 136.404 16.4421V17.2017H132.907V16.4421C132.907 15.0877 133.47 14.4607 134.658 14.4607M131.291 16.5546V19.3358C131.291 21.5021 132.474 22.6677 134.621 22.6677C136.767 22.6677 137.894 21.7232 137.967 19.7217H136.404C136.347 20.9234 135.804 21.4258 134.617 21.4258C133.429 21.4258 132.907 20.7787 132.907 19.4243V18.4999H138.004V16.5185C138.004 14.3682 136.837 13.2228 134.658 13.2228C132.478 13.2228 131.291 14.3883 131.291 16.5546ZM46.3088 22.6677C47.6433 22.6677 48.8473 22.3904 49.9573 21.8156V15.2042H45.9129V16.5948H48.3208V20.8912C47.7576 21.1324 47.023 21.2811 46.3292 21.2811C44.2968 21.2811 43.5255 20.3165 43.5255 17.8166V13.3514C43.5255 11.7036 44.4682 10.7229 46.0476 10.7229C47.627 10.7229 48.3412 11.4464 48.3779 13.0018H50.092C50.0349 10.5019 48.7575 9.33635 46.0476 9.33635C43.3377 9.33635 41.8726 10.7792 41.8726 13.4117V17.7443C41.8726 21.1726 43.2275 22.6717 46.3129 22.6717M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="#ffffff"/>
|
|
</g>
|
|
</svg>'''
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en" data-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Printer Inventory Report - West Jefferson Facility</title>
|
|
<link rel="icon" type="image/x-icon" href="{favicon_data_uri}">
|
|
<style>
|
|
:root {{
|
|
--primary-color: #2563eb;
|
|
--primary-dark: #1d4ed8;
|
|
--secondary-color: #10b981;
|
|
--warning-color: #f59e0b;
|
|
--danger-color: #ef4444;
|
|
--success-color: #22c55e;
|
|
--background: #f8fafc;
|
|
--surface: #ffffff;
|
|
--surface-secondary: #f8f9fa;
|
|
--card-background: #ffffff;
|
|
--text-primary: #1e293b;
|
|
--text-secondary: #64748b;
|
|
--text-muted: #9ca3af;
|
|
--border-color: #e2e8f0;
|
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
|
}}
|
|
|
|
[data-theme="dark"] {{
|
|
--background: #0f172a;
|
|
--surface: #1e293b;
|
|
--surface-secondary: #334155;
|
|
--card-background: #1e293b;
|
|
--text-primary: #f1f5f9;
|
|
--text-secondary: #94a3b8;
|
|
--text-muted: #64748b;
|
|
--border-color: #334155;
|
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
|
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
|
|
}}
|
|
|
|
* {{
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}}
|
|
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: var(--background);
|
|
padding: 20px;
|
|
color: var(--text-primary);
|
|
transition: background-color 0.3s, color 0.3s;
|
|
}}
|
|
|
|
.container {{
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
background: var(--surface);
|
|
border-radius: 12px;
|
|
box-shadow: var(--shadow-lg);
|
|
overflow: hidden;
|
|
}}
|
|
|
|
.header {{
|
|
background: var(--primary-color);
|
|
color: white;
|
|
padding: 30px 40px;
|
|
border-bottom: 3px solid var(--primary-dark);
|
|
}}
|
|
|
|
[data-theme="light"] .header {{
|
|
background: white;
|
|
color: var(--text-primary);
|
|
border-bottom: 3px solid var(--border-color);
|
|
box-shadow: var(--shadow-sm);
|
|
}}
|
|
|
|
[data-theme="light"] .header .subtitle {{
|
|
color: var(--text-secondary);
|
|
}}
|
|
|
|
[data-theme="light"] .header .timestamp {{
|
|
color: var(--text-secondary);
|
|
}}
|
|
|
|
[data-theme="light"] .btn-dark-mode {{
|
|
background: var(--primary-color);
|
|
border: 1px solid var(--primary-color);
|
|
color: white;
|
|
}}
|
|
|
|
[data-theme="light"] .btn-dark-mode:hover {{
|
|
background: var(--primary-dark);
|
|
}}
|
|
|
|
.header-content {{
|
|
display: grid;
|
|
grid-template-columns: 1fr auto 1fr;
|
|
align-items: center;
|
|
gap: 20px;
|
|
}}
|
|
|
|
.header-left {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-self: start;
|
|
}}
|
|
|
|
.header-logo {{
|
|
height: 50px;
|
|
width: auto;
|
|
}}
|
|
|
|
.header-text {{
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
justify-self: center;
|
|
}}
|
|
|
|
.header h1 {{
|
|
font-size: 2em;
|
|
margin-bottom: 5px;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
.header .subtitle {{
|
|
font-size: 0.95em;
|
|
opacity: 0.9;
|
|
}}
|
|
|
|
.header-right {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
justify-self: end;
|
|
}}
|
|
|
|
.header .timestamp {{
|
|
font-size: 0.85em;
|
|
opacity: 0.85;
|
|
text-align: right;
|
|
}}
|
|
|
|
.btn-dark-mode {{
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
color: white;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 0.9em;
|
|
transition: all 0.2s;
|
|
}}
|
|
|
|
.btn-dark-mode:hover {{
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}}
|
|
|
|
.nav-tabs {{
|
|
background: var(--surface);
|
|
border-bottom: 2px solid var(--border-color);
|
|
display: flex;
|
|
padding: 0 40px;
|
|
overflow-x: auto;
|
|
}}
|
|
|
|
.nav-tab {{
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
padding: 15px 25px;
|
|
cursor: pointer;
|
|
font-size: 0.95em;
|
|
font-weight: 500;
|
|
border-bottom: 3px solid transparent;
|
|
transition: all 0.2s;
|
|
white-space: nowrap;
|
|
}}
|
|
|
|
.nav-tab:hover {{
|
|
color: var(--primary-color);
|
|
background: var(--surface-secondary);
|
|
}}
|
|
|
|
.nav-tab.active {{
|
|
color: var(--primary-color);
|
|
border-bottom-color: var(--primary-color);
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.tab-content {{
|
|
display: none;
|
|
padding: 20px;
|
|
}}
|
|
|
|
.tab-content.active {{
|
|
display: block;
|
|
}}
|
|
|
|
.content {{
|
|
padding: 0;
|
|
}}
|
|
|
|
.section {{
|
|
margin-bottom: 25px;
|
|
}}
|
|
|
|
.section-title {{
|
|
font-size: 1.5em;
|
|
color: var(--text-primary);
|
|
margin-bottom: 15px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 3px solid var(--primary-color);
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.printer-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 15px;
|
|
}}
|
|
|
|
.printer-card {{
|
|
background: var(--card-background);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
padding: 12px;
|
|
box-shadow: var(--shadow-sm);
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}}
|
|
|
|
.printer-card:hover {{
|
|
transform: translateY(-2px);
|
|
box-shadow: var(--shadow-md);
|
|
}}
|
|
|
|
.printer-card h3 {{
|
|
color: var(--primary-color);
|
|
font-size: 1.05em;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.printer-info {{
|
|
margin-bottom: 4px;
|
|
font-size: 0.9em;
|
|
line-height: 1.4;
|
|
}}
|
|
|
|
.printer-info .label {{
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
display: inline-block;
|
|
width: 80px;
|
|
}}
|
|
|
|
.printer-info .value {{
|
|
color: var(--text-primary);
|
|
}}
|
|
|
|
.badge {{
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-size: 0.75em;
|
|
font-weight: 600;
|
|
margin-left: 8px;
|
|
}}
|
|
|
|
.badge-color {{
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}}
|
|
|
|
.badge-mono {{
|
|
background: #f3e5f5;
|
|
color: #7b1fa2;
|
|
}}
|
|
|
|
.toner-section {{
|
|
margin-top: 8px;
|
|
padding: 8px;
|
|
background: var(--surface-secondary);
|
|
border-radius: 4px;
|
|
border-left: 3px solid var(--primary-color);
|
|
}}
|
|
|
|
.toner-section h4 {{
|
|
font-size: 0.9em;
|
|
color: var(--primary-color);
|
|
margin-bottom: 6px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.toner-item {{
|
|
margin-bottom: 4px;
|
|
padding: 2px 0;
|
|
border-bottom: 1px solid #eee;
|
|
}}
|
|
|
|
.toner-item:last-child {{
|
|
border-bottom: none;
|
|
margin-bottom: 0;
|
|
}}
|
|
|
|
.toner-color {{
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 2px;
|
|
font-size: 0.85em;
|
|
}}
|
|
|
|
.toner-level {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}}
|
|
|
|
.progress-bar {{
|
|
flex-grow: 1;
|
|
height: 18px;
|
|
background: var(--border-color);
|
|
border-radius: 9px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}}
|
|
|
|
.progress-fill {{
|
|
height: 100%;
|
|
background: var(--primary-color);
|
|
transition: width 0.3s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-size: 0.7em;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.progress-low {{
|
|
background: var(--danger-color);
|
|
}}
|
|
|
|
.progress-medium {{
|
|
background: var(--warning-color);
|
|
}}
|
|
|
|
.progress-high {{
|
|
background: var(--success-color);
|
|
}}
|
|
|
|
.toner-cartridge {{
|
|
font-size: 0.75em;
|
|
color: var(--text-secondary);
|
|
margin-top: 2px;
|
|
padding-left: 0;
|
|
line-height: 1.3;
|
|
}}
|
|
|
|
.supplies-list {{
|
|
margin-top: 10px;
|
|
}}
|
|
|
|
.supply-item {{
|
|
padding: 6px 10px;
|
|
margin: 4px 0;
|
|
background: var(--surface-secondary);
|
|
border-left: 3px solid var(--primary-color);
|
|
border-radius: 3px;
|
|
font-size: 0.9em;
|
|
color: var(--text-primary);
|
|
}}
|
|
|
|
.model-section {{
|
|
margin-bottom: 35px;
|
|
background: var(--card-background);
|
|
padding: 25px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
}}
|
|
|
|
.model-section h3 {{
|
|
color: var(--text-primary);
|
|
font-size: 1.4em;
|
|
margin-bottom: 20px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.supply-category {{
|
|
margin-bottom: 20px;
|
|
}}
|
|
|
|
.supply-category h4 {{
|
|
color: var(--primary-color);
|
|
font-size: 1.1em;
|
|
margin-bottom: 12px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.supply-category ul {{
|
|
list-style: none;
|
|
padding-left: 0;
|
|
}}
|
|
|
|
.supply-category li {{
|
|
padding: 8px 12px;
|
|
margin: 5px 0;
|
|
background: var(--surface-secondary);
|
|
border-left: 3px solid var(--primary-color);
|
|
border-radius: 4px;
|
|
font-size: 0.95em;
|
|
}}
|
|
|
|
.consolidated-section {{
|
|
background: var(--surface-secondary);
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
margin-top: 20px;
|
|
}}
|
|
|
|
.consolidated-category {{
|
|
margin-bottom: 25px;
|
|
background: var(--card-background);
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
box-shadow: var(--shadow-sm);
|
|
}}
|
|
|
|
.consolidated-category h4 {{
|
|
color: var(--text-primary);
|
|
font-size: 1.2em;
|
|
margin-bottom: 15px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.consolidated-item {{
|
|
padding: 10px 15px;
|
|
margin: 8px 0;
|
|
background: var(--surface-secondary);
|
|
border-left: 4px solid var(--primary-color);
|
|
border-radius: 4px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}}
|
|
|
|
.consolidated-item .part-name {{
|
|
flex-grow: 1;
|
|
font-size: 0.95em;
|
|
color: var(--text-primary);
|
|
}}
|
|
|
|
.consolidated-item .model-count {{
|
|
background: var(--primary-color);
|
|
color: white;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 0.85em;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
margin-left: 15px;
|
|
}}
|
|
|
|
.stats-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}}
|
|
|
|
.stat-card {{
|
|
background: var(--primary-color);
|
|
color: white;
|
|
padding: 25px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
box-shadow: var(--shadow-md);
|
|
}}
|
|
|
|
.stat-card .number {{
|
|
font-size: 2.5em;
|
|
font-weight: 700;
|
|
margin-bottom: 5px;
|
|
}}
|
|
|
|
.stat-card .label {{
|
|
font-size: 1em;
|
|
opacity: 0.9;
|
|
}}
|
|
|
|
.alert-section {{
|
|
background: linear-gradient(135deg, var(--danger-color) 0%, #dc2626 100%);
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
margin-bottom: 30px;
|
|
color: white;
|
|
box-shadow: var(--shadow-md);
|
|
}}
|
|
|
|
.alert-section h2 {{
|
|
font-size: 1.5em;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}}
|
|
|
|
.alert-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
gap: 15px;
|
|
margin-top: 20px;
|
|
}}
|
|
|
|
.alert-item {{
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
backdrop-filter: blur(10px);
|
|
}}
|
|
|
|
.alert-item strong {{
|
|
display: block;
|
|
font-size: 1.05em;
|
|
margin-bottom: 5px;
|
|
}}
|
|
|
|
.alert-item .alert-details {{
|
|
font-size: 0.9em;
|
|
opacity: 0.95;
|
|
line-height: 1.6;
|
|
}}
|
|
|
|
.alert-item .alert-level {{
|
|
display: inline-block;
|
|
background: rgba(255, 255, 255, 0.25);
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
font-size: 0.85em;
|
|
font-weight: 600;
|
|
margin-top: 5px;
|
|
}}
|
|
|
|
.no-alerts {{
|
|
text-align: center;
|
|
padding: 20px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 6px;
|
|
font-size: 1.1em;
|
|
}}
|
|
|
|
.compatibility-card {{
|
|
background: var(--card-background);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
margin-bottom: 15px;
|
|
box-shadow: var(--shadow-sm);
|
|
}}
|
|
|
|
.compatibility-card.priority-high {{
|
|
border-left: 5px solid var(--success-color);
|
|
}}
|
|
|
|
.compatibility-card.priority-medium {{
|
|
border-left: 5px solid var(--warning-color);
|
|
}}
|
|
|
|
.compatibility-card.priority-critical {{
|
|
border-left: 5px solid var(--danger-color);
|
|
background: linear-gradient(to right, rgba(239, 68, 68, 0.1), var(--card-background));
|
|
}}
|
|
|
|
.compatibility-header {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.compatibility-header h3 {{
|
|
color: var(--text-primary);
|
|
font-size: 1.15em;
|
|
margin: 0;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.priority-badge {{
|
|
padding: 6px 14px;
|
|
border-radius: 20px;
|
|
font-size: 0.85em;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}}
|
|
|
|
.priority-badge.high {{
|
|
background: var(--success-color);
|
|
color: white;
|
|
}}
|
|
|
|
.priority-badge.medium {{
|
|
background: var(--warning-color);
|
|
color: white;
|
|
}}
|
|
|
|
.priority-badge.critical {{
|
|
background: var(--danger-color);
|
|
color: white;
|
|
}}
|
|
|
|
.compatibility-models {{
|
|
background: var(--surface-secondary);
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.compatibility-models strong {{
|
|
color: var(--primary-color);
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
}}
|
|
|
|
.compatibility-models ul {{
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}}
|
|
|
|
.compatibility-models li {{
|
|
background: var(--card-background);
|
|
padding: 5px 12px;
|
|
border-radius: 4px;
|
|
font-size: 0.9em;
|
|
border: 1px solid var(--border-color);
|
|
}}
|
|
|
|
.compatibility-supplies {{
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.compatibility-supplies strong {{
|
|
color: var(--text-primary);
|
|
display: block;
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.supply-group {{
|
|
background: var(--surface-secondary);
|
|
padding: 10px 15px;
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
border-left: 3px solid var(--primary-color);
|
|
}}
|
|
|
|
.supply-group .supply-type {{
|
|
font-weight: 600;
|
|
color: var(--primary-color);
|
|
margin-bottom: 5px;
|
|
}}
|
|
|
|
.supply-group ul {{
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}}
|
|
|
|
.supply-group li {{
|
|
padding: 3px 0;
|
|
color: var(--text-primary);
|
|
font-size: 0.9em;
|
|
}}
|
|
|
|
.compatibility-benefit {{
|
|
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
|
|
color: white;
|
|
padding: 12px 15px;
|
|
border-radius: 6px;
|
|
font-size: 0.95em;
|
|
line-height: 1.5;
|
|
}}
|
|
|
|
.compatibility-benefit strong {{
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
font-size: 0.9em;
|
|
opacity: 0.9;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}}
|
|
|
|
.inventory-match {{
|
|
background: var(--surface-secondary);
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
margin-top: 15px;
|
|
border: 1px dashed var(--border-color);
|
|
}}
|
|
|
|
.inventory-match strong {{
|
|
color: var(--primary-color);
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
}}
|
|
|
|
.inventory-match ul {{
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}}
|
|
|
|
.inventory-match li {{
|
|
padding: 5px 0;
|
|
color: var(--text-secondary);
|
|
font-size: 0.9em;
|
|
}}
|
|
|
|
.search-controls {{
|
|
display: flex;
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}}
|
|
|
|
.search-box {{
|
|
flex: 1;
|
|
min-width: 250px;
|
|
}}
|
|
|
|
.search-box input {{
|
|
width: 100%;
|
|
padding: 10px 15px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--card-background);
|
|
color: var(--text-primary);
|
|
font-size: 0.95em;
|
|
}}
|
|
|
|
.search-box input:focus {{
|
|
outline: none;
|
|
border-color: var(--primary-color);
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
}}
|
|
|
|
.sort-controls {{
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}}
|
|
|
|
.sort-controls label {{
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
font-size: 0.9em;
|
|
}}
|
|
|
|
.sort-controls select {{
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--card-background);
|
|
color: var(--text-primary);
|
|
font-size: 0.9em;
|
|
cursor: pointer;
|
|
}}
|
|
|
|
.sort-controls select:focus {{
|
|
outline: none;
|
|
border-color: var(--primary-color);
|
|
}}
|
|
|
|
@media print {{
|
|
body {{
|
|
background: white;
|
|
padding: 0;
|
|
}}
|
|
|
|
.container {{
|
|
box-shadow: none;
|
|
}}
|
|
|
|
.printer-card {{
|
|
break-inside: avoid;
|
|
}}
|
|
|
|
.btn-dark-mode {{
|
|
display: none;
|
|
}}
|
|
}}
|
|
|
|
@media (max-width: 768px) {{
|
|
.printer-grid {{
|
|
grid-template-columns: 1fr;
|
|
}}
|
|
|
|
.stats-grid {{
|
|
grid-template-columns: 1fr;
|
|
}}
|
|
|
|
.header h1 {{
|
|
font-size: 1.5em;
|
|
}}
|
|
|
|
.header-content {{
|
|
grid-template-columns: 1fr;
|
|
gap: 15px;
|
|
}}
|
|
|
|
.header-left {{
|
|
justify-self: center;
|
|
}}
|
|
|
|
.header-right {{
|
|
justify-self: center;
|
|
}}
|
|
}}
|
|
</style>
|
|
<script>
|
|
// Embedded SVG logos
|
|
const lightLogo = `{logo_svg}`;
|
|
const darkLogo = `{dark_logo_svg}`;
|
|
|
|
// Load saved theme and switch logo
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
const savedTheme = localStorage.getItem('theme') || 'dark';
|
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
|
|
|
const btn = document.getElementById('darkModeBtn');
|
|
if (btn) {{
|
|
btn.textContent = savedTheme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode';
|
|
}}
|
|
|
|
// Switch logo based on theme using embedded SVG
|
|
const logoContainer = document.getElementById('logoContainer');
|
|
if (logoContainer) {{
|
|
logoContainer.innerHTML = savedTheme === 'dark' ? darkLogo : lightLogo;
|
|
}}
|
|
}});
|
|
|
|
// Update logo when theme changes
|
|
function toggleDarkMode() {{
|
|
const html = document.documentElement;
|
|
const currentTheme = html.getAttribute('data-theme');
|
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
html.setAttribute('data-theme', newTheme);
|
|
localStorage.setItem('theme', newTheme);
|
|
|
|
const btn = document.getElementById('darkModeBtn');
|
|
btn.textContent = newTheme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode';
|
|
|
|
// Switch logo using embedded SVG
|
|
const logoContainer = document.getElementById('logoContainer');
|
|
if (logoContainer) {{
|
|
logoContainer.innerHTML = newTheme === 'dark' ? darkLogo : lightLogo;
|
|
}}
|
|
}}
|
|
|
|
// Search and filter printers
|
|
function searchPrinters() {{
|
|
const searchTerm = document.getElementById('printerSearch').value.toLowerCase();
|
|
const cards = document.querySelectorAll('.printer-card');
|
|
|
|
cards.forEach(card => {{
|
|
const text = card.textContent.toLowerCase();
|
|
if (text.includes(searchTerm)) {{
|
|
card.style.display = '';
|
|
}} else {{
|
|
card.style.display = 'none';
|
|
}}
|
|
}});
|
|
}}
|
|
|
|
// Sort printers
|
|
function sortPrinters() {{
|
|
const sortBy = document.getElementById('sortSelect').value;
|
|
const grid = document.querySelector('.printer-grid');
|
|
const cards = Array.from(document.querySelectorAll('.printer-card'));
|
|
|
|
cards.sort((a, b) => {{
|
|
if (sortBy === 'name') {{
|
|
const nameA = a.querySelector('h3').textContent.toLowerCase();
|
|
const nameB = b.querySelector('h3').textContent.toLowerCase();
|
|
return nameA.localeCompare(nameB);
|
|
}} else if (sortBy === 'model') {{
|
|
const modelA = a.querySelector('.printer-info .value').textContent.toLowerCase();
|
|
const modelB = b.querySelector('.printer-info .value').textContent.toLowerCase();
|
|
return modelA.localeCompare(modelB);
|
|
}} else if (sortBy === 'toner') {{
|
|
// Get lowest toner percentage
|
|
const getLowestToner = (card) => {{
|
|
const progressBars = card.querySelectorAll('.progress-fill');
|
|
let lowest = 100;
|
|
progressBars.forEach(bar => {{
|
|
const text = bar.textContent.trim();
|
|
const percent = parseInt(text);
|
|
if (!isNaN(percent) && percent < lowest) {{
|
|
lowest = percent;
|
|
}}
|
|
}});
|
|
return lowest;
|
|
}};
|
|
|
|
return getLowestToner(a) - getLowestToner(b);
|
|
}}
|
|
return 0;
|
|
}});
|
|
|
|
// Re-append sorted cards
|
|
cards.forEach(card => grid.appendChild(card));
|
|
}}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<div class="header-content">
|
|
<div class="header-left">
|
|
<div id="logoContainer" class="header-logo"></div>
|
|
</div>
|
|
<div class="header-text">
|
|
<h1>Printer Inventory Report</h1>
|
|
<div class="subtitle">West Jefferson Facility</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<div class="timestamp">Generated on<br>{datetime.now().strftime('%B %d, %Y at %I:%M %p')}</div>
|
|
<button class="btn-dark-mode" id="darkModeBtn" onclick="toggleDarkMode()">☀️ Light Mode</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation Tabs -->
|
|
<nav class="nav-tabs">
|
|
<button class="nav-tab active" onclick="switchTab(event, 'alerts')">Alerts</button>
|
|
<button class="nav-tab" onclick="switchTab(event, 'printers')">Printers</button>
|
|
<button class="nav-tab" onclick="switchTab(event, 'models')">Models</button>
|
|
<button class="nav-tab" onclick="switchTab(event, 'supplies')">Supplies</button>
|
|
<button class="nav-tab" onclick="switchTab(event, 'compatibility')">Compatibility</button>
|
|
</nav>
|
|
|
|
<div class="content">
|
|
<!-- Alerts Tab -->
|
|
<div id="alerts" class="tab-content active">"""
|
|
|
|
if low_supplies:
|
|
html += f"""
|
|
<div class="alert-section">
|
|
<h2>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
|
|
<path d="M12 9v4"></path>
|
|
<path d="M12 17h.01"></path>
|
|
</svg>
|
|
Supplies Requiring Immediate Replacement ({len(low_supplies)} items)
|
|
</h2>
|
|
<div class="alert-grid">
|
|
"""
|
|
for item in sorted(low_supplies, key=lambda x: (0 if x['status'] == 'MISSING' else 1, x['percentage'])):
|
|
if item['status'] == 'MISSING':
|
|
status_text = f"<strong>{item['color']} Toner:</strong> NOT INSTALLED"
|
|
alert_label = "MISSING CARTRIDGE - INSTALL NOW"
|
|
else:
|
|
status_text = f"<strong>{item['color']} Toner:</strong> {item['percentage']}% remaining"
|
|
alert_label = "CRITICAL - ORDER NOW"
|
|
|
|
html += f"""
|
|
<div class="alert-item">
|
|
<strong>{item['fqdn']}</strong>
|
|
<div class="alert-details">
|
|
<div>{status_text}</div>
|
|
<div>Part: {item['cartridge'][:80]}</div>
|
|
<div class="alert-level">{alert_label}</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
html += """
|
|
</div>
|
|
</div>
|
|
"""
|
|
else:
|
|
html += """
|
|
<div class="alert-section">
|
|
<h2>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
|
</svg>
|
|
Supply Status
|
|
</h2>
|
|
<div class="no-alerts">All printer supplies are above 10% - No immediate replacements needed</div>
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
|
|
<!-- Printers Tab -->
|
|
<div id="printers" class="tab-content">
|
|
<div class="section">
|
|
<h2 class="section-title">Individual Printer Status</h2>
|
|
<div class="search-controls">
|
|
<div class="search-box">
|
|
<input type="text" id="printerSearch" placeholder="Search by name, IP, model, or serial..." onkeyup="searchPrinters()">
|
|
</div>
|
|
<div class="sort-controls">
|
|
<label for="sortSelect">Sort by:</label>
|
|
<select id="sortSelect" onchange="sortPrinters()">
|
|
<option value="name">Printer Name</option>
|
|
<option value="model">Model</option>
|
|
<option value="toner">Lowest Toner</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="printer-grid">
|
|
"""
|
|
|
|
# Add each printer
|
|
for printer_ip, data in sorted(all_printers_data.items()):
|
|
printer_type = 'Color' if data.get('is_color') else 'Monochrome'
|
|
badge_class = 'badge-color' if data.get('is_color') else 'badge-mono'
|
|
fqdn = get_fqdn_from_ip(printer_ip)
|
|
|
|
html += f"""
|
|
<div class="printer-card">
|
|
<h3>{fqdn}</h3>
|
|
<div class="printer-info">
|
|
<span class="label">IP Address:</span>
|
|
<span class="value">{printer_ip}</span>
|
|
</div>
|
|
<div class="printer-info">
|
|
<span class="label">Model:</span>
|
|
<span class="value">{data.get('normalized_model', data.get('model', 'Unknown'))}</span>
|
|
<span class="badge {badge_class}">{printer_type}</span>
|
|
</div>
|
|
<div class="printer-info">
|
|
<span class="label">Serial:</span>
|
|
<span class="value">{data.get('serial', 'Unknown')}</span>
|
|
</div>
|
|
"""
|
|
|
|
# Add toner levels
|
|
if data.get('toner_levels'):
|
|
# Use "Ink Levels" for large format printers
|
|
supply_label = "Ink Levels" if data.get('is_large_format', False) else "Toner Levels"
|
|
html += f"""
|
|
<div class="toner-section">
|
|
<h4>{supply_label}</h4>
|
|
"""
|
|
# Map by part number for DesignJets, use index for standard printers
|
|
idx_to_color = {}
|
|
if data.get('is_large_format', False):
|
|
# Try to map by HP 730 part numbers
|
|
for idx in data.get('toner_levels', {}).keys():
|
|
if idx in data.get('supplies', {}):
|
|
desc = data['supplies'][idx].get('description', '')
|
|
color = map_hp730_color(desc)
|
|
if color:
|
|
idx_to_color[idx] = color
|
|
# Fallback if mapping failed
|
|
if not idx_to_color:
|
|
idx_to_color = {'1': 'Cartridge 1', '2': 'Cartridge 2', '3': 'Cartridge 3', '4': 'Cartridge 4', '5': 'Cartridge 5', '6': 'Cartridge 6'}
|
|
else:
|
|
idx_to_color = {'1': 'Black', '2': 'Cyan', '3': 'Magenta', '4': 'Yellow'}
|
|
|
|
# Define display order for colors
|
|
color_order = ['Matte Black', 'Photo Black', 'Cyan', 'Magenta', 'Yellow', 'Gray', 'Black']
|
|
|
|
# Sort indices by color order
|
|
sorted_indices = sorted(
|
|
[idx for idx in data['toner_levels'].keys() if idx in idx_to_color],
|
|
key=lambda x: color_order.index(idx_to_color[x]) if idx_to_color[x] in color_order else 999
|
|
)
|
|
|
|
for idx in sorted_indices:
|
|
color = idx_to_color.get(idx, f'Index {idx}')
|
|
if idx in data['toner_levels']:
|
|
# Check if this index actually contains toner/cartridge, not maintenance kit
|
|
if idx in data.get('supplies', {}):
|
|
desc = data['supplies'][idx].get('description', '')
|
|
desc_lower = desc.lower()
|
|
# Skip if it's clearly not a toner cartridge
|
|
if any(keyword in desc_lower for keyword in ['maintenance kit', 'fuser', 'transfer', 'drum cartridge kit']):
|
|
continue
|
|
|
|
display = data['toner_levels'][idx]
|
|
percentage = data.get('toner_percentages', {}).get(idx)
|
|
|
|
# Determine progress bar class
|
|
if percentage is not None:
|
|
if percentage < 20:
|
|
progress_class = 'progress-low'
|
|
elif percentage < 50:
|
|
progress_class = 'progress-medium'
|
|
else:
|
|
progress_class = 'progress-high'
|
|
width = max(percentage, 5) # Minimum width for visibility
|
|
else:
|
|
progress_class = ''
|
|
width = 0
|
|
|
|
html += f"""
|
|
<div class="toner-item">
|
|
<div class="toner-color">{color}</div>
|
|
<div class="toner-level">
|
|
"""
|
|
|
|
if percentage is not None:
|
|
html += f"""
|
|
<div class="progress-bar">
|
|
<div class="progress-fill {progress_class}" style="width: {width}%;">
|
|
{percentage}%
|
|
</div>
|
|
</div>
|
|
"""
|
|
else:
|
|
html += f"""
|
|
<span>{display}</span>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
"""
|
|
|
|
# Add cartridge info
|
|
if idx in data.get('supplies', {}):
|
|
desc = data['supplies'][idx].get('description', '')
|
|
if desc:
|
|
# Replace generic Xerox PNs with known specific ones (before normalizing)
|
|
model_name = data.get('normalized_model', data.get('model', ''))
|
|
enriched_desc = replace_generic_xerox_pn(desc, model_name)
|
|
# Normalize supply description
|
|
normalized_desc = normalize_supply_description(enriched_desc)
|
|
html += f"""
|
|
<div class="toner-cartridge">Part: {normalized_desc[:80]}</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
"""
|
|
|
|
# Add other supplies
|
|
if data.get('supplies'):
|
|
other_supplies = []
|
|
# Exclude main ink/toner cartridges (1-6 for DesignJets, 1-4 for standard printers)
|
|
excluded_indices = ['1', '2', '3', '4', '5', '6'] if data.get('is_large_format', False) else ['1', '2', '3', '4']
|
|
for idx, supply in data['supplies'].items():
|
|
if idx not in excluded_indices:
|
|
desc = supply.get('description', '')
|
|
if desc:
|
|
keywords = ['fuser', 'drum', 'waste', 'transfer', 'belt', 'roller', 'kit']
|
|
if any(keyword in desc.lower() for keyword in keywords):
|
|
# Fix unknown part numbers with known ones based on model
|
|
desc = fix_unknown_part_numbers(desc, data['model'])
|
|
# Normalize supply description to remove serial numbers
|
|
normalized_desc = normalize_supply_description(desc)
|
|
other_supplies.append(normalized_desc)
|
|
|
|
if other_supplies:
|
|
html += """
|
|
<div class="toner-section">
|
|
<h4>Other Supplies</h4>
|
|
<div class="supplies-list">
|
|
"""
|
|
for supply in sorted(set(other_supplies)):
|
|
html += f"""
|
|
<div class="supply-item">{supply[:90]}</div>
|
|
"""
|
|
html += """
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
# Add maintenance kit info if available
|
|
if data.get('maintenance_kit'):
|
|
maint = data['maintenance_kit']
|
|
if maint: # Only display if there's actually data
|
|
html += """
|
|
<div class="toner-section">
|
|
<h4>Maintenance Kit</h4>
|
|
"""
|
|
|
|
# Standard MIB percentage (for HP 4250, 4350 and similar older models)
|
|
percentage = None
|
|
current = None
|
|
maximum = None
|
|
if 'standard_current' in maint and 'standard_max' in maint:
|
|
try:
|
|
current = maint['standard_current']
|
|
maximum = maint['standard_max']
|
|
if maximum > 0:
|
|
percentage = int((current / maximum) * 100)
|
|
except:
|
|
pass
|
|
|
|
if percentage is not None:
|
|
# Use progress bar for Standard MIB maintenance kit
|
|
if percentage < 20:
|
|
progress_class = 'progress-low'
|
|
elif percentage < 50:
|
|
progress_class = 'progress-medium'
|
|
else:
|
|
progress_class = 'progress-high'
|
|
width = max(percentage, 5) # Minimum width for visibility
|
|
|
|
html += f"""
|
|
<div class="toner-item">
|
|
<div class="toner-color">Level</div>
|
|
<div class="toner-level">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill {progress_class}" style="width: {width}%;">
|
|
{percentage}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
if 'part_number' in maint:
|
|
html += f"""
|
|
<div class="cartridge-info">Cartridge: {maint['part_number']}</div>
|
|
"""
|
|
html += """
|
|
</div>
|
|
"""
|
|
else:
|
|
# Fallback to old format for other maintenance kit info
|
|
html += """
|
|
<div class="supplies-list">
|
|
"""
|
|
if 'part_number' in maint:
|
|
html += f"""
|
|
<div class="supply-item"><strong>Part Number:</strong> {maint['part_number']}</div>
|
|
"""
|
|
if 'printer_model' in maint:
|
|
html += f"""
|
|
<div class="supply-item"><strong>Printer Model:</strong> {maint['printer_model']}</div>
|
|
"""
|
|
|
|
# HP proprietary MIB remaining pages (for newer HP models like M404, M607)
|
|
if 'remaining' in maint:
|
|
# Format remaining pages
|
|
remaining = maint['remaining']
|
|
if isinstance(remaining, int):
|
|
html += f"""
|
|
<div class="supply-item"><strong>Remaining:</strong> {remaining:,} pages</div>
|
|
"""
|
|
else:
|
|
html += f"""
|
|
<div class="supply-item"><strong>Remaining:</strong> {remaining}</div>
|
|
"""
|
|
html += """
|
|
</div>
|
|
"""
|
|
html += """
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Models Tab -->
|
|
<div id="models" class="tab-content">
|
|
<div class="section">
|
|
<h2 class="section-title">Replacement Parts by Printer Model</h2>
|
|
"""
|
|
|
|
# Add each model
|
|
for model_name in sorted(models_data.keys()):
|
|
supplies = models_data[model_name]
|
|
|
|
# Categorize supplies
|
|
categorized = {
|
|
'Toner Cartridges': [],
|
|
'Drums': [],
|
|
'Fusers': [],
|
|
'Waste Toner': [],
|
|
'Transfer/Belt': [],
|
|
'Maintenance Kits': [],
|
|
'Other': []
|
|
}
|
|
|
|
# Filter out generic Xerox entries when we have specific PNs
|
|
filtered_supplies = filter_generic_supplies(set(supplies))
|
|
|
|
for supply_desc in sorted(filtered_supplies):
|
|
supply_lower = supply_desc.lower()
|
|
if 'toner' in supply_lower and 'waste' not in supply_lower:
|
|
categorized['Toner Cartridges'].append(supply_desc)
|
|
elif 'drum' in supply_lower:
|
|
categorized['Drums'].append(supply_desc)
|
|
elif 'fuser' in supply_lower:
|
|
categorized['Fusers'].append(supply_desc)
|
|
elif 'waste' in supply_lower:
|
|
categorized['Waste Toner'].append(supply_desc)
|
|
elif 'transfer' in supply_lower or 'belt' in supply_lower:
|
|
categorized['Transfer/Belt'].append(supply_desc)
|
|
elif 'maintenance' in supply_lower or 'kit' in supply_lower:
|
|
categorized['Maintenance Kits'].append(supply_desc)
|
|
else:
|
|
categorized['Other'].append(supply_desc)
|
|
|
|
html += f"""
|
|
<div class="model-section">
|
|
<h3>{model_name}</h3>
|
|
"""
|
|
|
|
for category, items in categorized.items():
|
|
if items:
|
|
html += f"""
|
|
<div class="supply-category">
|
|
<h4>{category}</h4>
|
|
<ul>
|
|
"""
|
|
for item in sorted(items):
|
|
html += f"""
|
|
<li>{item}</li>
|
|
"""
|
|
html += """
|
|
</ul>
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Supplies Tab -->
|
|
<div id="supplies" class="tab-content">
|
|
<div class="section">
|
|
<h2 class="section-title">Supply Inventory - Consolidated View</h2>
|
|
<div class="consolidated-section">
|
|
"""
|
|
|
|
for category_name, items in categories.items():
|
|
if items:
|
|
html += f"""
|
|
<div class="consolidated-category">
|
|
<h4>{category_name}</h4>
|
|
"""
|
|
for supply_desc, model_list in sorted(items):
|
|
model_count = len(model_list)
|
|
if model_count > 1:
|
|
count_text = f"{model_count} models"
|
|
else:
|
|
count_text = model_list[0][:40]
|
|
|
|
html += f"""
|
|
<div class="consolidated-item">
|
|
<div class="part-name">{supply_desc}</div>
|
|
<div class="model-count">{count_text}</div>
|
|
</div>
|
|
"""
|
|
html += """
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Compatibility Tab -->
|
|
<div id="compatibility" class="tab-content">
|
|
<div class="section">
|
|
<h2 class="section-title">Supply Compatibility & Consolidation Opportunities</h2>
|
|
<p style="color: var(--text-secondary); margin-bottom: 30px; font-size: 1.05em;">
|
|
Based on manufacturer specifications and cross-reference research, these printer models share compatible supplies.
|
|
Consolidating inventory around these groups can reduce SKU count and simplify purchasing.
|
|
</p>
|
|
"""
|
|
|
|
# Check which models from compatibility groups are actually in our inventory
|
|
inventory_models = set(models_data.keys())
|
|
|
|
# Group compatibility items by priority
|
|
priority_order = ['Critical', 'High', 'Medium']
|
|
for priority in priority_order:
|
|
groups_in_priority = [g for g in COMPATIBILITY_GROUPS if g['priority'] == priority]
|
|
|
|
for group in groups_in_priority:
|
|
# Check which models from this group are in our inventory
|
|
models_in_inventory = [m for m in group['models'] if m in inventory_models]
|
|
|
|
priority_class = group['priority'].lower()
|
|
html += f"""
|
|
<div class="compatibility-card priority-{priority_class}">
|
|
<div class="compatibility-header">
|
|
<h3>{group['name']}</h3>
|
|
<span class="priority-badge {priority_class}">{group['priority']} Value</span>
|
|
</div>
|
|
|
|
<div class="compatibility-models">
|
|
<strong>Compatible Models:</strong>
|
|
<ul>
|
|
"""
|
|
for model in group['models']:
|
|
in_inventory = ' ✓ In Inventory' if model in inventory_models else ''
|
|
html += f"""
|
|
<li>{model}{in_inventory}</li>
|
|
"""
|
|
html += """
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="compatibility-supplies">
|
|
<strong>Shared Supplies:</strong>
|
|
"""
|
|
for supply in group['shared_supplies']:
|
|
html += f"""
|
|
<div class="supply-group">
|
|
<div class="supply-type">{supply['type']}</div>
|
|
<ul>
|
|
"""
|
|
for part in supply['parts']:
|
|
html += f"""
|
|
<li>{part}</li>
|
|
"""
|
|
html += """
|
|
</ul>
|
|
</div>
|
|
"""
|
|
html += f"""
|
|
</div>
|
|
|
|
<div class="compatibility-benefit">
|
|
<strong>Consolidation Benefit:</strong>
|
|
{group['benefit']}
|
|
</div>
|
|
"""
|
|
|
|
# Show which printers in our inventory match this group
|
|
if models_in_inventory:
|
|
html += f"""
|
|
<div class="inventory-match">
|
|
<strong>Printers in Your Inventory ({len(models_in_inventory)} model{'s' if len(models_in_inventory) > 1 else ''}):</strong>
|
|
<ul>
|
|
"""
|
|
for model in models_in_inventory:
|
|
# Count how many printers of this model we have
|
|
printer_count = sum(1 for p_data in all_printers_data.values()
|
|
if p_data.get('normalized_model') == model)
|
|
html += f"""
|
|
<li>{model} ({printer_count} printer{'s' if printer_count > 1 else ''})</li>
|
|
"""
|
|
html += """
|
|
</ul>
|
|
</div>
|
|
"""
|
|
html += """
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function switchTab(event, tabId) {{
|
|
// Hide all tab contents
|
|
const tabContents = document.querySelectorAll('.tab-content');
|
|
tabContents.forEach(content => {{
|
|
content.classList.remove('active');
|
|
}});
|
|
|
|
// Remove active class from all tabs
|
|
const tabs = document.querySelectorAll('.nav-tab');
|
|
tabs.forEach(tab => {{
|
|
tab.classList.remove('active');
|
|
}});
|
|
|
|
// Show selected tab content
|
|
document.getElementById(tabId).classList.add('active');
|
|
|
|
// Mark selected tab as active
|
|
event.currentTarget.classList.add('active');
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html
|
|
|
|
|
|
def main():
|
|
"""Main function to extract summaries from all printer CSVs"""
|
|
output_dir = "output"
|
|
|
|
if not os.path.exists(output_dir):
|
|
print(f"Error: Output directory '{output_dir}' not found")
|
|
print("Please run snmp_scanner.py first")
|
|
sys.exit(1)
|
|
|
|
csv_files = list(Path(output_dir).glob("printer-*.csv"))
|
|
|
|
if not csv_files:
|
|
print(f"No CSV files found in {output_dir}/")
|
|
sys.exit(1)
|
|
|
|
print(f"Found {len(csv_files)} printer scan files")
|
|
print("\n" + "="*70)
|
|
print("PRINTER SNMP DATA SUMMARY")
|
|
print("="*70)
|
|
|
|
# Dictionary to collect models and their supplies
|
|
models_data = {}
|
|
all_printers_data = {}
|
|
|
|
for csv_file in sorted(csv_files):
|
|
# Extract IP from filename
|
|
filename = csv_file.stem
|
|
ip_match = re.search(r'printer-(\d+)-(\d+)-(\d+)-(\d+)', filename)
|
|
if ip_match:
|
|
printer_ip = '.'.join(ip_match.groups())
|
|
print_printer_summary(printer_ip, csv_file)
|
|
|
|
# Extract data for model summary
|
|
data = extract_key_data(csv_file)
|
|
model = data['model']
|
|
normalized_model = normalize_model_name(model)
|
|
|
|
# Store printer data with both original and normalized model
|
|
data['normalized_model'] = normalized_model
|
|
all_printers_data[printer_ip] = data
|
|
|
|
if normalized_model not in models_data:
|
|
models_data[normalized_model] = []
|
|
|
|
# Add all supplies for this model
|
|
for idx, supply in data['supplies'].items():
|
|
desc = supply.get('description', '')
|
|
if desc:
|
|
# Fix unknown part numbers with known ones based on model
|
|
desc = fix_unknown_part_numbers(desc, data['model'])
|
|
# Replace generic Xerox PNs with known specific ones (before normalizing)
|
|
enriched_desc = replace_generic_xerox_pn(desc, normalized_model)
|
|
# Normalize supply description to remove serial numbers
|
|
normalized_desc = normalize_supply_description(enriched_desc)
|
|
models_data[normalized_model].append(normalized_desc)
|
|
|
|
# Add maintenance kit info if available
|
|
if data['maintenance_kit']:
|
|
if 'model' in data['maintenance_kit']:
|
|
models_data[normalized_model].append(f"Maintenance Kit: {data['maintenance_kit']['model']}")
|
|
|
|
print(f"\n{'='*70}")
|
|
print("Summary complete!")
|
|
print(f"{'='*70}\n")
|
|
|
|
# Print models with their supplies
|
|
print_models_with_supplies(models_data)
|
|
|
|
# Generate HTML report
|
|
html_content = generate_html_report(models_data, all_printers_data)
|
|
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
|
html_file = f"output/printer_report_{timestamp}.html"
|
|
|
|
with open(html_file, 'w', encoding='utf-8') as f:
|
|
f.write(html_content)
|
|
|
|
print(f"\n{'='*70}")
|
|
print(f"HTML report generated: {html_file}")
|
|
print(f"Self-contained report with embedded logos and assets")
|
|
print(f"{'='*70}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|