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 ( '
' f'
{t}
' '
' ) 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'' ) 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'' ) append_log("drive generated") else: parts.append( f'' ) append_log("drive not correctly generated") parts.append("") parts.append("
 ' f'
{title}
' '
 ' '' '' '' '' '' '
' 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("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'' ) append_log("drive generated") if i % 2 == 1: parts.append("") if len(unplaced_indices) % 2 == 1: parts.append("") parts.append("
' '' '' '' '' '' '
' f'
' f'{line1}
' f'
' f'{line2}
' f'
' f'Drive: {line3} / {line4} / Temp: {line5}
' '
' f'{led_dot(led_color, high_contrast_switch)}' '
' '
") 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()