From fdbe23e1d9f3401e28f4fdcd21842ae9d8f02a49 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Tue, 14 Jul 2026 06:50:09 -0500 Subject: [PATCH] TRUSS-1: init code commit of truss --- TODO-msp.md | 80 +++ TODO-os.md | 73 +++ TODO.md | 80 +++ __pycache__/build.cpython-314.pyc | Bin 0 -> 29516 bytes build.py | 601 +++++++++++++++++ output/index.html | 789 ++++++++++++++++++++++ templates/board.html | 1006 +++++++++++++++++++++++++++++ templates/home.html | 134 ++++ 8 files changed, 2763 insertions(+) create mode 100644 TODO-msp.md create mode 100644 TODO-os.md create mode 100644 TODO.md create mode 100644 __pycache__/build.cpython-314.pyc create mode 100644 build.py create mode 100644 output/index.html create mode 100644 templates/board.html create mode 100644 templates/home.html diff --git a/TODO-msp.md b/TODO-msp.md new file mode 100644 index 0000000..71114fa --- /dev/null +++ b/TODO-msp.md @@ -0,0 +1,80 @@ +# Arcline MSP — TODO + +Kanban board for **Arcline MSP** — IT / managed-services track. ([arcline.it](https://arcline.it)) and +**The Arcline Project** ([arclineproject.org](https://arclineproject.org)). + +Move cards between sections as they progress: `Backlog → In Progress → In Review → Done`. +Tag every card `[IT]` or `[OS]` so the two tracks stay easy to scan in one board. + +--- + +## Codename Legend + +Codenames are architectural/masonry terms — fitting for a company named after an arch. + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | +|-------------|-----------------------------------------------|-------|-------------| +| Keystone | Customer portal + billing overhaul | IT | In Progress | +| Cornerstone | Arcline OS alpha → public ISO release | OS | In Progress | +| Buttress | `arcline-audit` hardening & CVE scan integration | OS | Backlog | +| Voussoir | Migration tool (cPanel/Plesk → Arcline) | IT/OS | Backlog | +| Springer | Status page + uptime monitoring rollout | IT | In Review | +| Rampart | Firewall/nftables default-deny profile pass | OS | Backlog | +| Lintel | Docs site (docs.arcline.it) restructure | IT | Backlog | +| Plinth | Referral program launch | IT | Backlog | +| Abutment | Workstation (KDE) build target | OS | Backlog | + + + + + + + + + + + + + + + + + +--- + +## Backlog + +- [ ] `[IT]` **Lintel** — restructure docs.arcline.it into per-tool sections +- [ ] `[IT]` **Plinth** — build referral program signup + tracking flow +- [ ] `[IT/OS]` **Voussoir** — spec `arcline-migrate` cPanel import mapping +- [ ] `[IT]` — Case studies page — draft first 2 client write-ups +- [ ] `[IT]` — Game server plans — add automated backup scheduling to portal + +## In Progress + +- [ ] `[IT]` **Keystone** — customer portal (`arcline-portal`) SSL + ticketing UI +- [ ] `[IT]` **Keystone** — Stripe billing (`arcline-billing`) subscription sync + +## In Review + +- [ ] `[IT]` **Springer** — status page generator (`arcline-status`) — awaiting Blake's sign-off + +## Done + +- [x] `[IT]` — Site relaunch — arcline.it copy + pricing page +- [x] `[IT]` — Acquired Saibeo Solutions, onboarded Ed Do as CTO + +## Notes + +- Board auto-generated from the master TODO.md + diff --git a/TODO-os.md b/TODO-os.md new file mode 100644 index 0000000..8b2f6f2 --- /dev/null +++ b/TODO-os.md @@ -0,0 +1,73 @@ +# Arcline OS — TODO + +Kanban board for **Arcline OS** — open-source / GPL track. ([arcline.it](https://arcline.it)) and +**The Arcline Project** ([arclineproject.org](https://arclineproject.org)). + +Move cards between sections as they progress: `Backlog → In Progress → In Review → Done`. +Tag every card `[IT]` or `[OS]` so the two tracks stay easy to scan in one board. + +--- + +## Codename Legend + +Codenames are architectural/masonry terms — fitting for a company named after an arch. + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | +|-------------|-----------------------------------------------|-------|-------------| +| Keystone | Customer portal + billing overhaul | IT | In Progress | +| Cornerstone | Arcline OS alpha → public ISO release | OS | In Progress | +| Buttress | `arcline-audit` hardening & CVE scan integration | OS | Backlog | +| Voussoir | Migration tool (cPanel/Plesk → Arcline) | IT/OS | Backlog | +| Springer | Status page + uptime monitoring rollout | IT | In Review | +| Rampart | Firewall/nftables default-deny profile pass | OS | Backlog | +| Lintel | Docs site (docs.arcline.it) restructure | IT | Backlog | +| Plinth | Referral program launch | IT | Backlog | +| Abutment | Workstation (KDE) build target | OS | Backlog | + + + + + + + + + + + + +--- + +## Backlog + +- [ ] `[OS]` **Buttress** — wire `arcline-audit` into CI for nightly CVE scans +- [ ] `[OS]` **Rampart** — finalize default-deny nftables ruleset, document opt-in exceptions +- [ ] `[OS]` **Abutment** — scope KDE Plasma workstation ISO target +- [ ] `[IT/OS]` **Voussoir** — spec `arcline-migrate` cPanel import mapping +- [ ] `[OS]` — Grafana dashboard pack — ship default node_exporter + Loki dashboards + +## In Progress + +- [ ] `[OS]` **Cornerstone** — btrfs snapshot rollback QA pass + +## In Review + +- [ ] `[OS]` — `arcline-dns` — multi-resolver propagation checker — code review open +- [ ] `[OS]` **Cornerstone** — ISO build pipeline (Debian Bookworm base) + +## Done + +- [x] `[OS]` — Site launch — arclineproject.org landing page +- [x] `[OS]` — Toolchain announcement — 11 Go tools listed publicly + +## Notes + +- Board auto-generated from the master TODO.md + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b038fd8 --- /dev/null +++ b/TODO.md @@ -0,0 +1,80 @@ +# Arcline — TODO + +In-house Kanban board for **Arcline IT** ([arcline.it](https://arcline.it)) and +**The Arcline Project** ([arclineproject.org](https://arclineproject.org)). + +Move cards between sections as they progress: `Backlog → In Progress → In Review → Done`. +Tag every card `[IT]` or `[OS]` so the two tracks stay easy to scan in one board. + +--- + +## Codename Legend + +Codenames are architectural/masonry terms — fitting for a company named after an arch. + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | +|-------------|-----------------------------------------------|-------|-------------| +| Keystone | Customer portal + billing overhaul | IT | In Progress | +| Cornerstone | Arcline OS alpha → public ISO release | OS | In Progress | +| Buttress | `arcline-audit` hardening & CVE scan integration | OS | Backlog | +| Voussoir | Migration tool (cPanel/Plesk → Arcline) | IT/OS | Backlog | +| Springer | Status page + uptime monitoring rollout | IT | In Review | +| Rampart | Firewall/nftables default-deny profile pass | OS | Backlog | +| Lintel | Docs site (docs.arcline.it) restructure | IT | Backlog | +| Plinth | Referral program launch | IT | Backlog | +| Abutment | Workstation (KDE) build target | OS | Backlog | + + + + + + + + +--- + +## Backlog + +- [ ] `[OS]` **Buttress** — wire `arcline-audit` into CI for nightly CVE scans +- [ ] `[OS]` **Rampart** — finalize default-deny nftables ruleset, document opt-in exceptions +- [ ] `[OS]` **Abutment** — scope KDE Plasma workstation ISO target +- [ ] `[IT]` **Lintel** — restructure docs.arcline.it into per-tool sections +- [ ] `[IT]` **Plinth** — build referral program signup + tracking flow +- [ ] `[IT/OS]` **Voussoir** — spec `arcline-migrate` cPanel import mapping +- [ ] `[IT]` — Case studies page — draft first 2 client write-ups +- [ ] `[OS]` — Grafana dashboard pack — ship default node_exporter + Loki dashboards +- [ ] `[IT]` — Game server plans — add automated backup scheduling to portal + +## In Progress + +- [ ] `[IT]` **Keystone** — customer portal (`arcline-portal`) SSL + ticketing UI +- [ ] `[IT]` **Keystone** — Stripe billing (`arcline-billing`) subscription sync +- [ ] `[OS]` **Cornerstone** — btrfs snapshot rollback QA pass + +## In Review + +- [ ] `[IT]` **Springer** — status page generator (`arcline-status`) — awaiting Blake's sign-off +- [ ] `[OS]` — `arcline-dns` — multi-resolver propagation checker — code review open +- [ ] `[OS]` **Cornerstone** — ISO build pipeline (Debian Bookworm base) + +## Done + +- [x] `[IT]` — Site relaunch — arcline.it copy + pricing page +- [x] `[OS]` — Site launch — arclineproject.org landing page +- [x] `[OS]` — Toolchain announcement — 11 Go tools listed publicly +- [x] `[IT]` — Acquired Saibeo Solutions, onboarded Ed Do as CTO +- [x] `[Test]` **Test** — Test + +## Notes + +- Keep `[IT]` (arcline.it, revenue-facing) and `[OS]` (arclineproject.org, GPL/open-source) + clearly tagged — some cards (like Voussoir) straddle both. +- New codenames go in the legend table before the card is created, not after. +- Source repos live on `git.arcline.it` — link commits/PRs in card text once work starts. + diff --git a/__pycache__/build.cpython-314.pyc b/__pycache__/build.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b1f92fea13d1de1bf923e83291101b0b60017ea GIT binary patch literal 29516 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 dict | None: + """Return in-memory data for *name*, loading from its file if needed.""" + if name not in BOARDS: + return None + if name not in _boards_data: + _boards_data[name] = parse_todo(BOARDS[name]) + return _boards_data[name] + + +def _find_card(board: str, card_id: str) -> tuple[dict, list[dict]] | None: + """Return (card, parent_column_cards_list) or None within *board*.""" + data = _get_board(board) + if data is None: + return None + for col in data["columns"]: + for card in col["cards"]: + if card["id"] == card_id: + return card, col["cards"] + return None + + +def _ensure_board_files() -> None: + """Create any missing board markdown files from TODO.md.""" + original = ROOT / "TODO.md" + if not original.exists(): + return + + for name, path in BOARDS.items(): + if path.exists(): + continue + data = parse_todo(original) + + # Customize title per board + if name == "msp": + data["_header_raw"] = data["_header_raw"].replace( + "# Arcline — TODO", "# Arcline MSP — TODO" + ).replace( + "In-house Kanban board for **Arcline IT** ", + "Kanban board for **Arcline MSP** — IT / managed-services track. ", + ) + # Filter cards: keep IT and IT/OS + for col in data["columns"]: + col["cards"] = [ + c for c in col["cards"] + if "IT" in c.get("track", "") + ] + # Filter legend: keep IT, IT/OS + data["legend"] = [ + r for r in data["legend"] + if "IT" in r.get("track", "") or "OS" not in r.get("track", "") + ] + elif name == "os": + data["_header_raw"] = data["_header_raw"].replace( + "# Arcline — TODO", "# Arcline OS — TODO" + ).replace( + "In-house Kanban board for **Arcline IT** ", + "Kanban board for **Arcline OS** — open-source / GPL track. ", + ) + # Filter cards: keep OS and IT/OS + for col in data["columns"]: + col["cards"] = [ + c for c in col["cards"] + if "OS" in c.get("track", "") + ] + # Filter legend: keep OS, IT/OS + data["legend"] = [ + r for r in data["legend"] + if "OS" in r.get("track", "") or "IT" not in r.get("track", "") + ] + + # Regenerate legend raw from filtered legend list + if data["legend"]: + legend_header = "| Codename | Initiative | Track | Status |" + legend_sep = "|-------------|-----------------------------------------------|-------|-------------|" + legend_rows = [ + f"| {r['codename']:<12} | {r['initiative']:<45} | {r['track']:<5} | {r['status']:<11} |" + for r in data["legend"] + ] + data["_legend_raw"] = "\n".join([legend_header, legend_sep] + legend_rows) + + # Update notes + data["_notes_raw"] = "## Notes\n\n- Board auto-generated from the master TODO.md\n" + + _save_board(data, path) + + +# --------------------------------------------------------------------------- +# Parser (parses a TODO.md file into structured data) +# --------------------------------------------------------------------------- + +def parse_todo(path: Path) -> dict: + """Parse a TODO.md file into a structured dictionary.""" + text = path.read_text() + return { + "header": _parse_header(text), + "legend": _parse_legend(text), + "columns": _parse_columns(text), + "notes": _parse_notes(text), + "_header_raw": _extract_header_raw(text), + "_pre_legend_raw": _extract_pre_legend_raw(text), + "_legend_raw": _extract_legend_raw(text), + "_post_legend_raw": _extract_post_legend_raw(text), + "_notes_raw": _extract_notes_raw(text), + } + + +def _parse_header(text: str) -> dict: + lines = text.splitlines() + title = "" + subtitle = "" + flow_line = "" + + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("# ") and not title: + title = stripped[2:].strip() + for j in range(i + 1, min(i + 8, len(lines))): + nxt = lines[j].strip() + if nxt.startswith("#") or nxt.startswith("---"): + break + if not nxt: + if subtitle: + break + continue + if not subtitle: + subtitle = nxt + else: + subtitle += " " + nxt + elif "Backlog" in stripped and "In Progress" in stripped: + flow_line = stripped + break + + return {"title": title, "subtitle": subtitle, "flow_line": flow_line} + + +def _parse_legend(text: str) -> list[dict]: + legends: list[dict] = [] + in_table = False + + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("| Codename"): + in_table = True + continue + if in_table: + if not stripped.startswith("|"): + break + if "---" in stripped: + continue + cells = [c.strip() for c in stripped.split("|")[1:-1]] + if len(cells) >= 4: + legends.append({ + "codename": cells[0], + "initiative": cells[1], + "track": cells[2], + "status": cells[3], + }) + return legends + + +def _parse_columns(text: str) -> list[dict]: + columns: list[dict] = [] + current_col: dict | None = None + + for line in text.splitlines(): + stripped = line.strip() + + if stripped.startswith("## Backlog"): + current_col = {"name": "Backlog", "cards": []} + columns.append(current_col) + elif stripped.startswith("## In Progress"): + current_col = {"name": "In Progress", "cards": []} + columns.append(current_col) + elif stripped.startswith("## In Review"): + current_col = {"name": "In Review", "cards": []} + columns.append(current_col) + elif stripped.startswith("## Done"): + current_col = {"name": "Done", "cards": []} + columns.append(current_col) + elif stripped.startswith("## ") and current_col and current_col["name"] != "Done": + break + + if current_col and _is_card_line(stripped): + card = _parse_card(stripped) + if card: + current_col["cards"].append(card) + + return columns + + +def _is_card_line(line: str) -> bool: + return line.startswith("- [ ]") or line.startswith("- [x]") + + +def _parse_card(line: str) -> dict | None: + id_match = re.search(r"", line) + card_id = id_match.group(1) if id_match else str(uuid.uuid4()) + clean_line = re.sub(r"", "", line).strip() + + checked = clean_line.startswith("- [x]") + rest = re.sub(r"^- \[[ x]\]\s*", "", clean_line) + + track_match = re.match(r"`\[([^\]]+)\]`", rest) + track = track_match.group(1) if track_match else "" + if track_match: + rest = rest[track_match.end():].strip() + + codename_match = re.match(r"\*\*([^*]+)\*\*", rest) + codename = codename_match.group(1) if codename_match else "" + if codename_match: + rest = rest[codename_match.end():].strip() + + rest = re.sub(r"^—\s*", "", rest) + description = rest.strip() + + return { + "id": card_id, + "track": track, + "codename": codename, + "description": description, + "checked": checked, + } + + +def _parse_notes(text: str) -> list[str]: + notes: list[str] = [] + in_notes = False + + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("## Notes"): + in_notes = True + continue + if in_notes: + if stripped.startswith("## "): + break + if stripped.startswith("- "): + notes.append(stripped[2:].strip()) + + return notes + + +# --------------------------------------------------------------------------- +# Raw-section extractors +# --------------------------------------------------------------------------- + +def _extract_header_raw(text: str) -> str: + lines = text.splitlines() + out: list[str] = [] + for line in lines: + if line.strip().startswith("## Codename Legend"): + break + out.append(line) + return "\n".join(out) + + +def _extract_pre_legend_raw(text: str) -> str: + lines = text.splitlines() + out: list[str] = [] + in_section = False + for line in lines: + if line.strip().startswith("## Codename Legend"): + in_section = True + if in_section: + out.append(line) + if line.strip().startswith("|"): + break + return "\n".join(out) + + +def _extract_legend_raw(text: str) -> str: + lines = text.splitlines() + out: list[str] = [] + in_table = False + for line in lines: + stripped = line.strip() + if stripped.startswith("| Codename"): + in_table = True + out.append(line) + continue + if in_table: + if stripped.startswith("|"): + out.append(line) + else: + if stripped == "": + out.append(line) + else: + break + return "\n".join(out) + + +def _extract_post_legend_raw(text: str) -> str: + lines = text.splitlines() + out: list[str] = [] + found_legend_end = False + found_sep = False + for line in lines: + stripped = line.strip() + if found_legend_end and not found_sep: + if stripped == "---": + found_sep = True + out.append(line) + elif stripped == "": + out.append(line) + continue + if found_sep: + if stripped.startswith("## "): + break + out.append(line) + continue + if stripped.startswith("## Codename Legend"): + found_legend_end = False + if stripped.startswith("|"): + found_legend_end = True + return "\n".join(out) + + +def _extract_notes_raw(text: str) -> str: + lines = text.splitlines() + out: list[str] = [] + in_notes = False + for line in lines: + if line.strip().startswith("## Notes"): + in_notes = True + if in_notes: + out.append(line) + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# Serializer +# --------------------------------------------------------------------------- + +def _card_to_md(card: dict) -> str: + checked = "x" if card["checked"] else " " + parts = [f"- [{checked}]"] + if card.get("track"): + parts.append(f"`[{card['track']}]`") + if card.get("codename"): + parts.append(f"**{card['codename']}**") + if card.get("description"): + parts.append(f"— {card['description']}") + line = " ".join(parts) + if card.get("id"): + line += f" " + return line + + +def _save_board(data: dict, path: Path | None = None) -> None: + """Persist board data to its markdown file.""" + if path is None: + return + + sections: list[str] = [] + + sections.append(data["_header_raw"].rstrip()) + sections.append("") + sections.append(data["_pre_legend_raw"].rstrip()) + sections.append("") + sections.append(data["_legend_raw"].rstrip()) + sections.append("") + sections.append(data["_post_legend_raw"].rstrip()) + + for col in data["columns"]: + sections.append("") + sections.append(f"## {col['name']}") + sections.append("") + for card in col["cards"]: + sections.append(_card_to_md(card)) + + sections.append("") + sections.append(data["_notes_raw"].rstrip()) + sections.append("") + + path.write_text("\n".join(sections) + "\n") + + +# --------------------------------------------------------------------------- +# Jinja2 filter +# --------------------------------------------------------------------------- + +@app.template_filter("inline_md") +def _inline_markdown(text: str) -> Markup: + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"\*([^*]+)\*", r"\1", text) + text = re.sub(r"\[([^\]]+)]\(([^)]+)\)", r'\1', text) + return Markup(text) + + +# --------------------------------------------------------------------------- +# Routes — pages +# --------------------------------------------------------------------------- + +@app.route("/") +def home(): + """Board selector.""" + board_list = [ + {"slug": slug, "title": _get_board(slug)["header"]["title"] if _get_board(slug) else slug.upper()} + for slug in BOARDS + ] + return render_template_string( + (TEMPLATES_DIR / "home.html").read_text(), + boards=board_list, + ) + + +@app.route("/") +def board_page(board: str): + """Render a specific Kanban board.""" + data = _get_board(board) + if data is None: + return redirect(url_for("home")) + return render_template_string( + (TEMPLATES_DIR / "board.html").read_text(), + board_slug=board, + **{k: v for k, v in data.items() if not k.startswith("_")}, + ) + + +# --------------------------------------------------------------------------- +# Routes — REST API (per-board) +# --------------------------------------------------------------------------- + +@app.route("/api//board") +def api_board(board: str): + data = _get_board(board) + if data is None: + return jsonify({"ok": False, "error": "Unknown board"}), 404 + return jsonify({ + "header": data["header"], + "legend": data["legend"], + "columns": data["columns"], + "notes": data["notes"], + }) + + +@app.route("/api//cards", methods=["POST"]) +def create_card(board: str): + data = _get_board(board) + if data is None: + return jsonify({"ok": False, "error": "Unknown board"}), 404 + + payload = request.get_json(silent=True) or {} + column_name = payload.get("column", "Backlog") + card = { + "id": str(uuid.uuid4()), + "track": payload.get("track", ""), + "codename": payload.get("codename", ""), + "description": payload.get("description", ""), + "checked": bool(payload.get("checked", False)), + } + + for col in data["columns"]: + if col["name"] == column_name: + col["cards"].append(card) + _save_board(data, BOARDS[board]) + return jsonify({"ok": True, "card": card}), 201 + + return jsonify({"ok": False, "error": f"Unknown column: {column_name}"}), 400 + + +@app.route("/api//cards/", methods=["PUT"]) +def update_card(board: str, card_id: str): + data = _get_board(board) + if data is None: + return jsonify({"ok": False, "error": "Unknown board"}), 404 + + payload = request.get_json(silent=True) or {} + result = _find_card(board, card_id) + if result is None: + return jsonify({"ok": False, "error": "Card not found"}), 404 + + card, _cards_list = result + for field in ("track", "codename", "description", "checked"): + if field in payload: + card[field] = payload[field] + + _save_board(data, BOARDS[board]) + return jsonify({"ok": True, "card": card}) + + +@app.route("/api//cards//move", methods=["POST"]) +def move_card(board: str, card_id: str): + data = _get_board(board) + if data is None: + return jsonify({"ok": False, "error": "Unknown board"}), 404 + + payload = request.get_json(silent=True) or {} + target_column = payload.get("column") + if not target_column: + return jsonify({"ok": False, "error": "Missing 'column' field"}), 400 + + result = _find_card(board, card_id) + if result is None: + return jsonify({"ok": False, "error": "Card not found"}), 404 + + card, source_list = result + target_list = None + for col in data["columns"]: + if col["name"] == target_column: + target_list = col["cards"] + break + + if target_list is None: + return jsonify({"ok": False, "error": f"Unknown column: {target_column}"}), 400 + + source_list.remove(card) + target_list.append(card) + _save_board(data, BOARDS[board]) + return jsonify({"ok": True, "card": card}) + + +@app.route("/api//cards/", methods=["DELETE"]) +def delete_card(board: str, card_id: str): + data = _get_board(board) + if data is None: + return jsonify({"ok": False, "error": "Unknown board"}), 404 + + result = _find_card(board, card_id) + if result is None: + return jsonify({"ok": False, "error": "Card not found"}), 404 + + card, cards_list = result + cards_list.remove(card) + _save_board(data, BOARDS[board]) + return jsonify({"ok": True}) + + +@app.route("/api//reload", methods=["POST"]) +def reload_board(board: str): + if board not in BOARDS: + return jsonify({"ok": False, "error": "Unknown board"}), 404 + _boards_data.pop(board, None) + _get_board(board) + return jsonify({"ok": True}) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + _ensure_board_files() + + if "--build" in sys.argv: + env = app.jinja_env + template = env.from_string((TEMPLATES_DIR / "board.html").read_text()) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + for slug in BOARDS: + data = _get_board(slug) + if data is None: + continue + html = template.render( + board_slug=slug, + **{k: v for k, v in data.items() if not k.startswith("_")}, + ) + (OUTPUT_DIR / f"{slug}.html").write_text(html) + print(f"✅ Rendered {slug} → {OUTPUT_DIR / f'{slug}.html'}") + else: + print("🚀 Serving Kanban boards at http://127.0.0.1:5000") + for slug in BOARDS: + print(f" /{slug}") + app.run(debug=True, host="127.0.0.1", port=5000) + + +if __name__ == "__main__": + main() + diff --git a/output/index.html b/output/index.html new file mode 100644 index 0000000..10aa67e --- /dev/null +++ b/output/index.html @@ -0,0 +1,789 @@ + + + + + +Arcline — TODO + + + + + + + +
+

+ + Arcline — TODO +

+

In-house Kanban board for Arcline IT (arcline.it) and The Arcline Project (arclineproject.org).

+
+ + + + +
+ Track: + IT arcline.it + OS arclineproject.org + IT/OS shared + Move cards between sections as they progress: `Backlog → In Progress → In Review → Done`. +
+ + + + + +
+

Codename Legend

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodenameInitiativeTrackStatus
KeystoneCustomer portal + billing overhaul + + IT + + + + In Progress +
CornerstoneArcline OS alpha → public ISO release + + OS + + + + In Progress +
Buttress`arcline-audit` hardening & CVE scan integration + + OS + + + + Backlog +
VoussoirMigration tool (cPanel/Plesk → Arcline) + + IT/OS + + + + Backlog +
SpringerStatus page + uptime monitoring rollout + + IT + + + + In Review +
RampartFirewall/nftables default-deny profile pass + + OS + + + + Backlog +
LintelDocs site (docs.arcline.it) restructure + + IT + + + + Backlog +
PlinthReferral program launch + + IT + + + + Backlog +
AbutmentWorkstation (KDE) build target + + OS + + + + Backlog +
+
+ + + + + +
+ +
+
+

Backlog

+ 9 +
+
    + +
  • +
    + + + OS + + + Buttress + +
    +

    wire `arcline-audit` into CI for nightly CVE scans

    +
  • + +
  • +
    + + + OS + + + Rampart + +
    +

    finalize default-deny nftables ruleset, document opt-in exceptions

    +
  • + +
  • +
    + + + OS + + + Abutment + +
    +

    scope KDE Plasma workstation ISO target

    +
  • + +
  • +
    + + + IT + + + Lintel + +
    +

    restructure docs.arcline.it into per-tool sections

    +
  • + +
  • +
    + + + IT + + + Plinth + +
    +

    build referral program signup + tracking flow

    +
  • + +
  • +
    + + + IT/OS + + + Voussoir + +
    +

    spec `arcline-migrate` cPanel import mapping

    +
  • + +
  • +
    + + + IT + + +
    +

    Case studies page — draft first 2 client write-ups

    +
  • + +
  • +
    + + + OS + + +
    +

    Grafana dashboard pack — ship default node_exporter + Loki dashboards

    +
  • + +
  • +
    + + + IT + + +
    +

    Game server plans — add automated backup scheduling to portal

    +
  • + +
+
+ +
+
+

In Progress

+ 4 +
+
    + +
  • +
    + + + IT + + + Keystone + +
    +

    customer portal (`arcline-portal`) SSL + ticketing UI

    +
  • + +
  • +
    + + + IT + + + Keystone + +
    +

    Stripe billing (`arcline-billing`) subscription sync

    +
  • + +
  • +
    + + + OS + + + Cornerstone + +
    +

    ISO build pipeline (Debian Bookworm base)

    +
  • + +
  • +
    + + + OS + + + Cornerstone + +
    +

    btrfs snapshot rollback QA pass

    +
  • + +
+
+ +
+
+

In Review

+ 2 +
+
    + +
  • +
    + + + IT + + + Springer + +
    +

    status page generator (`arcline-status`) — awaiting Blake's sign-off

    +
  • + +
  • +
    + + + OS + + +
    +

    `arcline-dns` — multi-resolver propagation checker — code review open

    +
  • + +
+
+ +
+
+

Done

+ 4 +
+
    + +
  • +
    + + + IT + + +
    +

    Site relaunch — arcline.it copy + pricing page

    +
  • + +
  • +
    + + + OS + + +
    +

    Site launch — arclineproject.org landing page

    +
  • + +
  • +
    + + + OS + + +
    +

    Toolchain announcement — 11 Go tools listed publicly

    +
  • + +
  • +
    + + + IT + + +
    +

    Acquired Saibeo Solutions, onboarded Ed Do as CTO

    +
  • + +
+
+ +
+ + + + + +
+

Notes

+
    + +
  • Keep `[IT]` (arcline.it, revenue-facing) and `[OS]` (arclineproject.org, GPL/open-source)
  • + +
  • New codenames go in the legend table before the card is created, not after.
  • + +
  • Source repos live on `git.arcline.it` — link commits/PRs in card text once work starts.
  • + +
+
+ + + + + + + + + diff --git a/templates/board.html b/templates/board.html new file mode 100644 index 0000000..8b7abbb --- /dev/null +++ b/templates/board.html @@ -0,0 +1,1006 @@ + + + + + +{{ header.title }} + + + + + + + +
+
+

+ + {{ header.title }} +

+

{{ header.subtitle | inline_md }}

+
+ +
+ + + + +
+ Track: + IT arcline.it + OS arclineproject.org + IT/OS shared + {{ header.flow_line }} +
+ + + + +{% if legend %} +
+

Codename Legend

+ + + + + + + + + + + {% for item in legend %} + + + + + + + {% endfor %} + +
CodenameInitiativeTrackStatus
{{ item.codename }}{{ item.initiative }} + {% if item.track == "IT" %} + IT + {% elif item.track == "OS" %} + OS + {% elif "IT" in item.track and "OS" in item.track %} + {{ item.track }} + {% else %} + {{ item.track }} + {% endif %} + + {% set status_lower = item.status.lower().replace(' ', '') %} + {{ item.status }} +
+
+{% endif %} + + + + +
+ {% for col in columns %} +
+
+

{{ col.name }}

+ {{ col.cards|length }} +
+
    + {% for card in col.cards %} +
  • +
    + +
    +
    + {% if card.track %} + {% set track_class = 'both' if '/' in card.track else card.track.lower() %} + {{ card.track }} + {% endif %} + {% if card.codename %} + {{ card.codename }} + {% endif %} +
    +

    {{ card.description }}

    +
  • + {% endfor %} +
+ +
+ {% endfor %} +
+ + + + +{% if notes %} +
+

Notes

+
    + {% for note in notes %} +
  • {{ note }}
  • + {% endfor %} +
+
+{% endif %} + + + + + + + + + + + + + + +
+ + + + + + + + + diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..404c841 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,134 @@ + + + + + +Arcline Kanban + + + + +
+

Arcline Kanban

+

Select a board to get started.

+ + + + +
+ + + +