import json, argparse, os, sys, stat
from html import escape
from typing import Tuple, Dict, List
##### V 0.14
##### Stand alone script to generate the html render for disklayout_config.json
__version__ = "0.14"
__script_directory__ = os.getcwd()
__script_path__ = os.path.abspath(__file__)
__script_name__ = os.path.basename(__script_path__)
__input_default__ = os.path.join(__script_directory__, "disklayout_config.json")
__output_render__ = os.path.join(__script_directory__, "case_render.html")
__output_render_snipplet__ = os.path.join(__script_directory__, "case_email_snippet.html")
# --- COLORS AND OTHER VARIABLE ---
__c_placeholder_slot__ = "#7E57C2"
__c_placeholder_slot_2__ = "#4527A0"
__c_HC_placeholder_slot__ = "#9FA6B2"
__cols__ = 4 # just a fallback
__cols_n_limit__ = 5 # used to determine the max limit to decrease cols width to 200
__rows_n_limit__ = 10 # used to determine the max number to decrease gap
__cols_breakout__ = 8 #8 used to determine the max limit to auto rotate the web rich version
__cols_w__ = 275 # standard cols width
__cols_wR__ = 200 # reduced cols width
__row_h__ = 58 # standard rows height
__row_hR__ = 70 # increased rows height
def assert_outputs_secure_or_abort() -> bool:
"""
Security check
"""
append_log("testing symlink - size on output files")
max_output_size = 100 * 1024
for out_path in (__output_render__, __output_render_snipplet__):
if os.path.lexists(out_path) and os.path.islink(out_path):
process_output(True, f"[SECURITY ERROR]: output file '{out_path}' is a symlink; refusing to write.", 1)
if os.path.exists(out_path):
try:
st = os.stat(out_path, follow_symlinks=False)
except Exception as e:
process_output(True, f"[ERROR]: cannot stat output file '{out_path}': {e}", 1)
if st.st_size > max_output_size:
process_output( True, f"[SECURITY ERROR]: output file '{out_path}' is too large ({st.st_size} bytes > {max_output_size}); refusing to write.", 1, )
append_log("test pass, checking directory")
dirs = {
os.path.dirname(os.path.abspath(__output_render__)) or __script_directory__,
os.path.dirname(os.path.abspath(__output_render_snipplet__)) or __script_directory__,
}
for d in dirs:
try:
st = os.stat(d, follow_symlinks=True)
except Exception as e:
process_output(True, f"[ERROR]: cannot stat directory '{d}': {e}", 1)
append_log("test pass, checking permission")
if bool(st.st_mode & stat.S_IWOTH):
print(f"[SECURITY ERROR]: directory '{d}' is writable by non-privileged users; operation will not be aborted, until script is in BETA test .")
# process_output(True, f"[SECURITY ERROR]: directory '{d}' is writable by non-privileged users; operation aborted.", 1)
return True
def append_log(content):
if DEBUG_ENABLED:
print(content)
def process_output(error, detail="", exit_code=None):
"""
Centralized output response
- version str error bool detail string exit_code 0 (ok) 1 (ko) or None (ignore)
"""
response = json.dumps({"version": __version__,"error": error, "detail": detail}, ensure_ascii=False)
append_log(f"{detail}")
print(response)
if exit_code is not None:
sys.exit(exit_code)
# ---------- IO ----------
def load_json(path: str):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception as e:
process_output(True, f"Can't load json file: {e}", 1)
# ---------- Config helpers ----------
def get_cols(case: dict) -> int:
"""Return number of columns from case.layout.cols or fallback."""
try:
cols = int(case.get("layout", {}).get("cols") or __cols__)
return cols if cols > 0 else __cols__
except (TypeError, ValueError):
return __cols__
def rows_from_case(case: dict, cols: int) -> int:
"""Use explicit 'rows' if provided; otherwise compute from the highest active slot."""
layout = case.get("layout", {})
rows = layout.get("rows")
if isinstance(rows, int) and rows > 0:
return rows
active = layout.get("activeSlots") or []
if not active:
return 1
try:
max_slot = max(int(x) for x in active)
except ValueError:
return 1
return max(1, ((max_slot - 1) // cols) + 1)
def handle_rotate_layout(case: dict) -> bool:
""" new property to rotate the layout vertically -> true horizontal -> false default """
try:
rotate = case.get("layout", {}).get("rotate")
is_rotate_enabled = str(rotate).lower() in ("true", "1", "yes")
cols = get_cols(case)
return is_rotate_enabled or cols > __cols_breakout__
except Exception:
return False
def validate_case(original_case: dict | None) -> Tuple[dict, bool]:
"""
Ensure a valid case dict. If missing or invalid activeSlots, return a
minimal structure and a flag False to indicate it's not a real case.
"""
if not isinstance(original_case, dict):
return (
{"id": "no-case", "name": "No case selected", "layout": {"activeSlots": [], "rows": 1, "cols": __cols__}},
False,
)
layout = original_case.get("layout") or {}
active = layout.get("activeSlots")
if not isinstance(active, list) or len(active) == 0:
c = dict(original_case)
c.setdefault("name", original_case.get("id", "No case selected"))
c["layout"] = {"activeSlots": [], "rows": layout.get("rows", 1), "cols": layout.get("cols", __cols__)}
return (c, False)
return (original_case, True)
def is_real_drive(b: dict) -> bool:
return isinstance(b, dict) and str(b.get("status", "")).strip().lower() != "empty"
def build_pos_map_from_bays(bays_list: list) -> Tuple[Dict[int, dict], List[int]]:
"""
Map absolute slot -> info (serial, bay_index) and collect unplaced drives.
Returns (pos_to_info, unplaced_indices)
"""
pos_to_info: Dict[int, dict] = {}
unplaced: List[int] = []
for idx, b in enumerate(bays_list or []):
if not isinstance(b, dict):
continue
status = str(b.get("status", "")).strip().lower() # ?? TO ASK why empty bays?
if status == "empty":
continue
slot = b.get("slot", None)
try:
pos = int(slot)
if pos <= 0:
raise ValueError
except (TypeError, ValueError):
unplaced.append(idx)
continue
serial = (b.get("serial") or "").strip()
pos_to_info[pos] = {"serial": serial, "bay_index": idx}
return pos_to_info, unplaced
def normalize_drives_from_bays(bays_list: list) -> Dict[str, dict]:
"""Build serial -> drive fields lookup (for color/labels/extra)."""
lookup: Dict[str, dict] = {}
for b in bays_list or []:
if not isinstance(b, dict):
continue
status = str(b.get("status", "")).strip().lower()
if status == "empty":
continue
serial = (b.get("serial") or "").strip()
if serial:
lookup[serial] = b
return lookup
def get_placeholder_map(case: dict) -> Dict[int, str]:
"""Return {slot_id: title} for placeholderSlots."""
layout = (case or {}).get("layout", {}) or {}
placeholders = layout.get("placeholderSlots") or []
out: Dict[int, str] = {}
for it in placeholders:
if not isinstance(it, dict):
continue
try:
sid = int(it.get("id"))
except (TypeError, ValueError):
continue
title = (it.get("title") or "").strip()
if sid > 0 and title:
out[sid] = title
return out
def get_sep_slots(case: dict) -> List[int]:
"""Return list of sepSlots (as integers)."""
layout = (case or {}).get("layout", {}) or {}
raw = layout.get("sepSlots") or []
out = []
for it in raw:
try:
out.append(int(it))
except (TypeError, ValueError):
continue
return out
def get_cols_width(cols: int) -> int:
""" centralize the calc of the cols width """
return __cols_w__ if cols <= __cols_n_limit__ else __cols_wR__
def get_rows_height(vertical_rotation: bool) -> int:
""" centralize the calc of the rows height """
return __row_h__ if not vertical_rotation else __row_hR__
def get_gap(cols: int, rows: int) -> int:
""" centralize the calc of the gap width """
return 22 if cols <= __cols_n_limit__ and rows <= __rows_n_limit__ else 10
def slot_rotation(vertical_rotation: bool):
""" centralize the rotation of the active slot on hover """
return " transform: rotate(-90deg) scale(1.50); transform-origin: center; z-index: 10; transition: transform 0.15s ease-in-out;" if vertical_rotation else ""
# ---------- Domain ----------
def get_field(d: dict, key: str, default: str = "–", fmt_temp: bool = False) -> str:
"""
Generic safe getter for drive fields.
"""
if not isinstance(d, dict):
return default
val = d.get(key, "")
if val is None or val == "":
return default
if fmt_temp:
if isinstance(val, (int, float)) and not str(val).endswith("°C"):
return f"{val}°C"
return str(val)
return str(val).strip()
def drive_led_color(d: dict) -> str:
c = get_field(d, "drive_color", "").lower()
return c if c in ("green", "yellow", "red", "orange") else "blank"
def drive_led_icon(color: str) -> tuple[str, str]:
icons = {
"green": ("✅", "OK"),
"yellow": ("⚠️", "WARNING"),
"orange": ("⚠️", "WARNING"),
"red": ("❌", "CRITICAL"),
}
return icons.get(color, ("❔", "UNKNOWN"))
def drive_label(d: dict) -> str:
return get_field(d, "serial", "") or get_field(d, "drive_id", "disk")
def drive_pool(d: dict) -> str:
return get_field(d, "pool", "--SPARE--")
def drive_temp(d: dict) -> str:
t = d.get("drive_temp")
if t is None or t == "":
return "–"
return f"{t}°C" if isinstance(t, (int, float)) and not str(t).endswith("°C") else str(t)
def drive_capacity(d: dict) -> str:
return get_field(d, "capacity")
def drive_id(d: dict) -> str:
return get_field(d, "drive_id")
def render_drive_line(d: dict, bay_idx, high_contrast_switch: bool = False) -> str:
label = escape(drive_label(d))
pool = escape(drive_pool(d))
temp = escape(drive_temp(d))
led = drive_led_color(d)
led_render = f'
'
if high_contrast_switch:
ic, lb = drive_led_icon(led)
led_render = f' {ic} {lb}
'
did = escape(drive_id(d))
cap = escape(drive_capacity(d))
return (
f''
'
'
f'
{label}
'
f'
{pool if pool else " "}
'
f'
Drive: {did} / {cap} / Temp: {temp}
'
'
'
f' {led_render}'
'
'
)
def render_placeholder_slot(title: str) -> str:
t = escape(title)
return (
''
)
def render_sep_slot() -> str:
return (
''
)
def led_dot(color: str, high_contrast_switch: bool = False) -> str:
"""Return led element color or the icon if high_contrast switch is enabled"""
if high_contrast_switch:
ic, lb = drive_led_icon(color)
return f'{ic} {lb}'
colors = {
"green": "#00ff55",
"yellow": "#ffd100",
"red": "#ff3b3b",
"orange": "#E4A11B",
"blank": "#9e9e9e",
}
c = colors.get(color, "#9e9e9e")
return f''
def led_background_color(color: str, high_contrast_switch: bool = False) -> str:
"""Return a light cell background color based on LED color."""
color = (color or "blank").lower()
if high_contrast_switch:
return "#F0F0F0"
else:
colors = {
"green": "#3da94f",
"yellow": "#d6a31e",
"red": "#b32121",
"orange": "#D87904",
"blank": "#9e9e9e",
}
c = colors.get(color, "#9e9e9e")
return c
def render_row_break(pos: int, cols: int, total_slots: int) -> str:
"""
add on need a row break to accomplish the css rule of the web rich version to work properly
"""
if cols > 0 and pos % cols == 0 and pos < total_slots:
return '
'
return ""
def break_string(val: str) -> str:
if not val:
return ""
return "
".join(val)
# ------------------------------------------------------------------
# ------------------------- HTML BUILDING --------------------------
# ------------------------------------------------------------------
# ---------- WEB: rich document with modal and all detail ----------
def render_web_html(
case: dict,
rows: int,
cols: int,
pos_to_info: dict,
drive_lookup: dict,
bays_list: list,
unplaced_indices: List[int],
has_real_case: bool,
high_contrast_switch: bool
) -> str:
append_log("getting active slots")
active = [int(x) for x in case["layout"]["activeSlots"]]
append_log("getting case name")
name = case.get("name", case.get("id", "Case"))
total_slots = cols * rows
append_log("getting bays")
bays_json = json.dumps(bays_list, ensure_ascii=False)
append_log("check for the case orientation")
vertical_rotation = handle_rotate_layout(case)
append_log(f"need rotation? {vertical_rotation}")
append_log("calculating css variables")
gap = get_gap(cols, rows)
colswidth = get_cols_width(cols)
rowsheight = get_rows_height(vertical_rotation) #__row_h__
unpl_colswidth, unpl_rowsheight = colswidth, rowsheight
if vertical_rotation:
append_log("swapping col-row")
colswidth, rowsheight = rowsheight, colswidth
rotate_slot = slot_rotation(vertical_rotation)
append_log("set main colors")
colors_block = f"""
.box-red{{background:linear-gradient(180deg,#b32121,#7a1414);border:1px solid #a32020}}
.box-yellow{{background:linear-gradient(180deg,#d6a31e,#927213);border:1px solid #c59616}}
.box-green{{background:linear-gradient(180deg,#3da94f,#237a33);border:1px solid #166b2d}}
.box-orange{{background:linear-gradient(180deg,#e97822,#a34f0c);border:1px solid #d26510}}
.box-blank{{background:linear-gradient(180deg,#7b7b7b,#4f4f4f);border:1px solid #5e5e5e}}
.slot.placeholder.box-blank{{opacity:1; background: linear-gradient(180deg, {__c_placeholder_slot__}, {__c_placeholder_slot_2__});}}
.slot.separator.box-blank{{opacity:1; background: linear-gradient(180deg, {__c_placeholder_slot__}, {__c_placeholder_slot_2__});}}
.slot .line-1{{font-weight:800;color:#fff;font-size:13px;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px}}
.slot .line-2{{font-weight:600;color:#cfe7ff;font-size:11px;opacity:.95;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px}}
.slot .line-3{{font-weight:600;color:#cccccc;font-size:9px;opacity:.9}}
"""
if high_contrast_switch:
append_log("colors swapped for the high contrast version")
colors_block = f"""
.box-red{{background:#f0f0f0;border:1px solid #1E3A5F}}
.box-yellow{{background:#f0f0f0;border:1px solid #9A4F15}}
.box-green{{background:#f0f0f0;border:1px solid #234F22}}
.box-orange{{background:#f0f0f0;border:1px solid #802828}}
.box-blank{{background:#f0f0f0;border:1px solid #4A4A4A}}
.slot.placeholder.box-blank{{opacity:1; background: {__c_HC_placeholder_slot__};}}
.slot.separator.box-blank{{opacity:1; background: {__c_HC_placeholder_slot__};}}
.slot .line-1{{font-weight:800;color:#000000;font-size:13px;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px}}
.slot .line-2{{font-weight:600;color:#000000;font-size:11px;opacity:.95;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px}}
.slot .line-3{{font-weight:600;color:#000000;font-size:9px;opacity:.9}}
"""
append_log("set the main structure")
head = f"""
{escape(name)}
{(f'{escape(name)}
' if has_real_case else '')}
{("
No case selected — some drives are unplaced.
" if (unplaced_indices and not has_real_case) else "")}
{("
" if has_real_case else '')}"""
parts = [head]
total_slots = cols * rows
append_log("mapping placeholder slots")
placeholder_map = get_placeholder_map(case)
append_log("mapping active slots")
active = [int(x) for x in case["layout"]["activeSlots"]]
active_set = set(active) | set(placeholder_map.keys())
append_log("mapping separator slots")
sep_slots = set(get_sep_slots(case))
active_set |= sep_slots
append_log("all slots have been merged. Start cycling")
for pos in range(1, total_slots + 1):
append_log(f">> {pos}")
if pos not in active_set:
append_log("empty bay")
hide_empty_bay = ' style="display:none;"' if not has_real_case else ''
parts.append(f'
')
continue
if pos in placeholder_map:
append_log("placeholder bay")
parts.append(render_placeholder_slot(placeholder_map[pos]))
parts.append(render_row_break(pos, cols, total_slots))
continue
if pos in sep_slots:
append_log("separator bay")
parts.append(render_sep_slot())
parts.append(render_row_break(pos, cols, total_slots))
continue
append_log("real drive!")
info = pos_to_info.get(pos)
serial = info.get("serial") if info else None
bay_idx = info.get("bay_index") if info else None
append_log(f"{serial}")
if serial and serial in drive_lookup:
d = drive_lookup[serial]
parts.append(render_drive_line(d, bay_idx, high_contrast_switch))
append_log("drive generated")
else:
hide_empty_bay = ' style="display:none;"' if not has_real_case else ''
parts.append(f'
')
append_log("drive not correctly generated")
# Unplaced drives panel (only if present and not hidden)
if has_real_case:
parts.append("
") # close .case
if unplaced_indices:
append_log("some drive are not placed, generating unplaced code")
parts.append('
')
parts.append('
Unplaced drives
')
parts.append('
')
for idx in unplaced_indices:
append_log(f"generating {idx}")
b = bays_list[idx] or {}
parts.append(render_drive_line(b, idx, high_contrast_switch))
append_log("drive generated")
parts.append('
')
append_log("building modal and script")
# Modal + script
parts.append(f"""
""")
append_log("got the snipplet")
return "\n".join(parts)
# -----------------------------------------------------------
# ---------- EMAIL: second improved inline version ----------
def render_table_email_snippet(
case: dict,
rows: int,
cols: int,
pos_to_info: dict,
drive_lookup: dict,
bays_list: list,
unplaced_indices: list[int],
has_real_case: bool,
high_contrast_switch: bool,
) -> str:
"""
Table-based email snippet inline version
"""
append_log("mapping placeholder slots")
placeholder_map = get_placeholder_map(case)
append_log("mapping separator slots")
sep_slots = set(get_sep_slots(case))
append_log("getting active slots")
active = set(int(x) for x in case["layout"].get("activeSlots", []))
active_set = active | set(placeholder_map.keys()) | sep_slots
append_log("all slots have been merged. Start cycling")
append_log("calculating inline style variables")
vertical_rotation = handle_rotate_layout(case)
colswidth = get_cols_width(cols)
colsheight = get_rows_height(cols)
cellpadding = 0 if not vertical_rotation else 10
unpl_colswidth, unpl_rowsheight = colswidth+30, colsheight
if vertical_rotation:
append_log("swapping col-row")
colswidth, colsheight = colsheight, colswidth
parts: list[str] = []
append_log("building main table")
parts.append(
f"""
"""
)
if has_real_case:
append_log("start cycling")
for r in range(rows):
parts.append("")
for c in range(cols):
pos = r * cols + c + 1
append_log(f">> {pos}")
if pos not in active_set:
append_log("empty bay")
parts.append(
f'| | '
)
continue
if pos in placeholder_map:
append_log("placeholder bay")
title = escape(placeholder_map[pos])
if vertical_rotation:
title = break_string(title)
bg = border = __c_HC_placeholder_slot__ if high_contrast_switch else __c_placeholder_slot__
text_color = "#000000" if high_contrast_switch else "#FFFFFF"
parts.append(
f''
f' {title} '
' | '
)
continue
if pos in sep_slots:
append_log("separator bay")
bg = __c_HC_placeholder_slot__ if high_contrast_switch else __c_placeholder_slot__
parts.append(
f' | '
)
continue
append_log("real drive!")
info = pos_to_info.get(pos)
serial = info.get("serial") if info else None
if serial and serial in drive_lookup:
d = drive_lookup[serial]
append_log(f"{drive_label(d)}")
line1 = escape(drive_label(d))
if vertical_rotation:
line1 = break_string(line1)
line2 = escape(drive_pool(d))
line3 = escape(drive_id(d))
line4 = escape(drive_capacity(d))
line5 = escape(drive_temp(d))
led_color = (d.get("led") or d.get("drive_color") or "blank")
led_color = led_color.lower() if isinstance(led_color, str) else "blank"
bg = border = led_background_color(led_color, high_contrast_switch)
text_color_1 = "#000000" if high_contrast_switch else "#FFFFFF"
text_color_2 = "#000000" if high_contrast_switch else "#FFFFFF"
parts.append(
f''
''
''
'| '
f' {line1} '
f'{"" if vertical_rotation else ""}{line2} '
f'Drive: {line3} {"" if vertical_rotation else "/"} {line4} {"" if vertical_rotation else "/"} Temp: {line5} '
' | '
''
f'{led_dot(led_color, high_contrast_switch)}'
' | '
' '
' '
' | '
)
append_log("drive generated")
else:
parts.append(
f' | '
)
append_log("drive not correctly generated")
parts.append("
")
parts.append("
")
append_log("first table generated")
# Unplaced drives
if unplaced_indices:
append_log("some drive are not placed, generating unplaced table")
parts.append(
''
)
for i, idx in enumerate(unplaced_indices):
append_log(f"generating {idx}")
if i % 2 == 0:
parts.append("")
b = bays_list[idx] or {}
line1 = escape(drive_label(b))
line2 = escape(drive_pool(b))
line3 = escape(drive_id(b))
line4 = escape(drive_capacity(b))
line5 = escape(drive_temp(b))
led_color = (b.get("led") or b.get("drive_color") or "blank")
led_color = led_color.lower() if isinstance(led_color, str) else "blank"
bg = border = led_background_color(led_color, high_contrast_switch)
text_color = "#000000" if high_contrast_switch else "#FFFFFF"
parts.append(
f''
''
''
'| '
f' '
f'{line1} '
f''
f'{line2} '
f''
f'Drive: {line3} / {line4} / Temp: {line5} '
' | '
''
f'{led_dot(led_color, high_contrast_switch)}'
' | '
' '
' '
' | '
)
append_log("drive generated")
if i % 2 == 1:
parts.append("
")
if len(unplaced_indices) % 2 == 1:
parts.append("")
parts.append("
")
append_log("unplaced table generated")
append_log("got the snipplet")
return "\n".join(parts)
# ---------- CLI ----------
def main():
parser = argparse.ArgumentParser(description="Render case layout: output a rich web page plus a smaller snipplet")
parser.add_argument("--config", default=__input_default__, help=f"Path to unified JSON configuration. Default: {__input_default__}")
parser.add_argument("--debug_enabled", help="OPTIONAL use to let the script debug all steps into log files. Usefull for troubleshooting", action="store_true")
args = parser.parse_args()
global DEBUG_ENABLED
DEBUG_ENABLED = args.debug_enabled
append_log(f"## script version {__version__} ##")
append_log(f"start preliminary security check")
assert_outputs_secure_or_abort()
append_log(f"loading configuration file")
cfg = load_json(args.config)
if "bays" not in cfg:
process_output(True, "[ERROR] Config JSON must contain 'bays'", 1)
append_log(f"preparing data...")
# Safe case selection (handles missing/empty activeSlots)
case_raw = cfg.get("case")
case, has_real_case = validate_case(case_raw)
cols = get_cols(case)
rows = rows_from_case(case, cols)
bays_list = cfg.get("bays", []) or []
pos_to_info, unplaced_indices = build_pos_map_from_bays(bays_list)
drive_lookup = normalize_drives_from_bays(bays_list)
if not has_real_case:
unplaced_indices = [i for i, b in enumerate(bays_list) if is_real_drive(b)]
pos_to_info = {}
# check for high contrast swtich - unused from case defintion bool(cfg.get("case", {}).get("high_contrast", False))
high_contrast_switch = str(cfg.get("high_contrast", "false")).lower() == "true"
append_log(f"rendering rich text file")
try:
web_html = render_web_html(
case, rows, cols, pos_to_info, drive_lookup, bays_list, unplaced_indices, has_real_case, high_contrast_switch)
with open(__output_render__, "w", encoding="utf-8") as f:
f.write(web_html)
except Exception as e:
process_output(True, f"Something wrong on rendering richt text file {e}", 1)
append_log(f"rendering table-based email snipplet")
try:
table_email_html = render_table_email_snippet(
case, rows, cols, pos_to_info, drive_lookup, bays_list, unplaced_indices, has_real_case, high_contrast_switch)
with open(__output_render_snipplet__, "w", encoding="utf-8") as f:
f.write(table_email_html)
except Exception as e:
process_output(True, f"Something wrong on rendering table-based email snipplet file {e}", 1)
process_output(False, "Operation completed", 0)
if __name__ == "__main__":
main()