TRUSS-1: init code commit of truss

This commit is contained in:
Blake Ridgway
2026-07-14 06:50:09 -05:00
commit fdbe23e1d9
8 changed files with 2763 additions and 0 deletions

80
TODO-msp.md Normal file
View File

@@ -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 <!-- id:85851b3d-ecd3-46ea-9870-4cc515670b13 -->
- [ ] `[IT]` **Plinth** — build referral program signup + tracking flow <!-- id:b4c0421a-3201-424f-adce-f8399d848c05 -->
- [ ] `[IT/OS]` **Voussoir** — spec `arcline-migrate` cPanel import mapping <!-- id:86df261e-e1c8-48af-9aa9-551a0da4fe14 -->
- [ ] `[IT]` — Case studies page — draft first 2 client write-ups <!-- id:88242e2b-7218-42bd-9790-bd33e7649f3e -->
- [ ] `[IT]` — Game server plans — add automated backup scheduling to portal <!-- id:c4aae5af-f5f9-418b-b078-a6534c69f27e -->
## In Progress
- [ ] `[IT]` **Keystone** — customer portal (`arcline-portal`) SSL + ticketing UI <!-- id:d4ccf112-a52e-42c4-9ff5-0b986969cfb5 -->
- [ ] `[IT]` **Keystone** — Stripe billing (`arcline-billing`) subscription sync <!-- id:e059813f-0c1f-4294-859d-b0b7d36ec5db -->
## In Review
- [ ] `[IT]` **Springer** — status page generator (`arcline-status`) — awaiting Blake's sign-off <!-- id:cfb2ee5e-ffe4-4c0a-9a2e-9e4204979ffd -->
## Done
- [x] `[IT]` — Site relaunch — arcline.it copy + pricing page <!-- id:cea24874-17bd-41c5-8f7f-2a9d044fc4d4 -->
- [x] `[IT]` — Acquired Saibeo Solutions, onboarded Ed Do as CTO <!-- id:82d6863d-e3bb-44e8-9dc0-b4af01bae50e -->
## Notes
- Board auto-generated from the master TODO.md

73
TODO-os.md Normal file
View File

@@ -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 <!-- id:9db1be1d-f875-4ebc-a55a-151c11bfe5b1 -->
- [ ] `[OS]` **Rampart** — finalize default-deny nftables ruleset, document opt-in exceptions <!-- id:734eae2d-7617-4296-abee-4575553cea6b -->
- [ ] `[OS]` **Abutment** — scope KDE Plasma workstation ISO target <!-- id:1ea38888-c4a7-4f3b-91c1-120b422abaef -->
- [ ] `[IT/OS]` **Voussoir** — spec `arcline-migrate` cPanel import mapping <!-- id:86df261e-e1c8-48af-9aa9-551a0da4fe14 -->
- [ ] `[OS]` — Grafana dashboard pack — ship default node_exporter + Loki dashboards <!-- id:fe511c5e-6de9-4eab-8b99-06c763505e82 -->
## In Progress
- [ ] `[OS]` **Cornerstone** — btrfs snapshot rollback QA pass <!-- id:b4ff74b8-f284-4e4e-bb12-a85ad0e70344 -->
## In Review
- [ ] `[OS]``arcline-dns` — multi-resolver propagation checker — code review open <!-- id:bcd256ac-3ab9-4ad2-8506-c3c3c40d3079 -->
- [ ] `[OS]` **Cornerstone** — ISO build pipeline (Debian Bookworm base) <!-- id:1621bd98-ebd2-46b4-bd20-2c37bb900f48 -->
## Done
- [x] `[OS]` — Site launch — arclineproject.org landing page <!-- id:e184a3a5-a072-4214-b65d-846a9c09c8c0 -->
- [x] `[OS]` — Toolchain announcement — 11 Go tools listed publicly <!-- id:ec7c5021-f0ac-49b7-b90e-5ce23c9ed360 -->
## Notes
- Board auto-generated from the master TODO.md

80
TODO.md Normal file
View File

@@ -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 <!-- id:9db1be1d-f875-4ebc-a55a-151c11bfe5b1 -->
- [ ] `[OS]` **Rampart** — finalize default-deny nftables ruleset, document opt-in exceptions <!-- id:734eae2d-7617-4296-abee-4575553cea6b -->
- [ ] `[OS]` **Abutment** — scope KDE Plasma workstation ISO target <!-- id:1ea38888-c4a7-4f3b-91c1-120b422abaef -->
- [ ] `[IT]` **Lintel** — restructure docs.arcline.it into per-tool sections <!-- id:85851b3d-ecd3-46ea-9870-4cc515670b13 -->
- [ ] `[IT]` **Plinth** — build referral program signup + tracking flow <!-- id:b4c0421a-3201-424f-adce-f8399d848c05 -->
- [ ] `[IT/OS]` **Voussoir** — spec `arcline-migrate` cPanel import mapping <!-- id:86df261e-e1c8-48af-9aa9-551a0da4fe14 -->
- [ ] `[IT]` — Case studies page — draft first 2 client write-ups <!-- id:88242e2b-7218-42bd-9790-bd33e7649f3e -->
- [ ] `[OS]` — Grafana dashboard pack — ship default node_exporter + Loki dashboards <!-- id:fe511c5e-6de9-4eab-8b99-06c763505e82 -->
- [ ] `[IT]` — Game server plans — add automated backup scheduling to portal <!-- id:c4aae5af-f5f9-418b-b078-a6534c69f27e -->
## In Progress
- [ ] `[IT]` **Keystone** — customer portal (`arcline-portal`) SSL + ticketing UI <!-- id:d4ccf112-a52e-42c4-9ff5-0b986969cfb5 -->
- [ ] `[IT]` **Keystone** — Stripe billing (`arcline-billing`) subscription sync <!-- id:e059813f-0c1f-4294-859d-b0b7d36ec5db -->
- [ ] `[OS]` **Cornerstone** — btrfs snapshot rollback QA pass <!-- id:b4ff74b8-f284-4e4e-bb12-a85ad0e70344 -->
## In Review
- [ ] `[IT]` **Springer** — status page generator (`arcline-status`) — awaiting Blake's sign-off <!-- id:cfb2ee5e-ffe4-4c0a-9a2e-9e4204979ffd -->
- [ ] `[OS]``arcline-dns` — multi-resolver propagation checker — code review open <!-- id:bcd256ac-3ab9-4ad2-8506-c3c3c40d3079 -->
- [ ] `[OS]` **Cornerstone** — ISO build pipeline (Debian Bookworm base) <!-- id:1621bd98-ebd2-46b4-bd20-2c37bb900f48 -->
## Done
- [x] `[IT]` — Site relaunch — arcline.it copy + pricing page <!-- id:cea24874-17bd-41c5-8f7f-2a9d044fc4d4 -->
- [x] `[OS]` — Site launch — arclineproject.org landing page <!-- id:e184a3a5-a072-4214-b65d-846a9c09c8c0 -->
- [x] `[OS]` — Toolchain announcement — 11 Go tools listed publicly <!-- id:ec7c5021-f0ac-49b7-b90e-5ce23c9ed360 -->
- [x] `[IT]` — Acquired Saibeo Solutions, onboarded Ed Do as CTO <!-- id:82d6863d-e3bb-44e8-9dc0-b4af01bae50e -->
- [x] `[Test]` **Test** — Test <!-- id:75c90304-4cc5-4bcb-8ab1-865987a3216d -->
## 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.

Binary file not shown.

601
build.py Normal file
View File

@@ -0,0 +1,601 @@
#!/usr/bin/env python3
"""Arcline Kanban — multi-board Kanban with CRUD over markdown files."""
from __future__ import annotations
import re
import sys
import uuid
from pathlib import Path
from flask import Flask, jsonify, redirect, render_template_string, request, url_for
from markupsafe import Markup
ROOT = Path(__file__).resolve().parent
TEMPLATES_DIR = ROOT / "templates"
OUTPUT_DIR = ROOT / "output"
# Board registry — name → file path
BOARDS: dict[str, Path] = {
"msp": ROOT / "TODO-msp.md",
"os": ROOT / "TODO-os.md",
}
app = Flask(__name__, template_folder=str(TEMPLATES_DIR))
# Per-board in-memory state — loaded on first access, synced on write
_boards_data: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# Board helpers
# ---------------------------------------------------------------------------
def _get_board(name: str) -> 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"<!--\s*id:(\S+)\s*-->", line)
card_id = id_match.group(1) if id_match else str(uuid.uuid4())
clean_line = re.sub(r"<!--\s*id:\S+\s*-->", "", 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" <!-- id:{card['id']} -->"
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"<strong>\1</strong>", text)
text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)
text = re.sub(r"\[([^\]]+)]\(([^)]+)\)", r'<a href="\2">\1</a>', 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("/<board>")
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>/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/<board>/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/<board>/cards/<card_id>", 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/<board>/cards/<card_id>/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/<board>/cards/<card_id>", 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/<board>/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()

789
output/index.html Normal file
View File

@@ -0,0 +1,789 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arcline — TODO</title>
<style>
/* ------------------------------------------------------------------ */
/* Reset & base */
/* ------------------------------------------------------------------ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--border: #dfe1e6;
--text: #172b4d;
--muted: #6b778c;
--track-it: #0052cc;
--track-os: #36b37e;
--track-both: #6554c0;
--backlog: #dfe1e6;
--inprogress: #ffc400;
--inreview: #00b8d9;
--done: #36b37e;
--card-shadow: 0 1px 3px rgba(9,30,66,0.08);
--card-hover: 0 2px 6px rgba(9,30,66,0.14);
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
/* ------------------------------------------------------------------ */
/* Header */
/* ------------------------------------------------------------------ */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 24px 32px;
}
header h1 {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
header h1 .logo {
background: linear-gradient(135deg, #0052cc, #6554c0);
color: #fff;
width: 32px; height: 32px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
}
header .subtitle {
color: var(--muted);
margin-top: 4px;
font-size: 0.95rem;
}
/* ------------------------------------------------------------------ */
/* Legend */
/* ------------------------------------------------------------------ */
.legend-bar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 32px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
font-size: 0.85rem;
color: var(--muted);
}
.legend-bar strong { color: var(--text); }
.track-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
}
.track-it { background: var(--track-it); }
.track-os { background: var(--track-os); }
.track-both { background: var(--track-both); }
/* ------------------------------------------------------------------ */
/* Legend table */
/* ------------------------------------------------------------------ */
.legend-table-wrap {
padding: 20px 32px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.legend-table-wrap h2 {
font-size: 1rem;
margin-bottom: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
table.legend {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
table.legend th {
text-align: left;
padding: 8px 12px;
border-bottom: 2px solid var(--border);
color: var(--muted);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
table.legend td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
table.legend tr:last-child td { border-bottom: none; }
.status-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: #fff;
}
.status-backlog { background: var(--backlog); color: #42526e; }
.status-inprogress { background: var(--inprogress); color: #172b4d; }
.status-inreview { background: var(--inreview); }
.status-done { background: var(--done); }
/* ------------------------------------------------------------------ */
/* Board */
/* ------------------------------------------------------------------ */
.board {
display: flex;
gap: 20px;
padding: 24px 32px;
overflow-x: auto;
min-height: calc(100vh - 340px);
align-items: flex-start;
}
.column {
flex: 1;
min-width: 280px;
max-width: 380px;
background: #ebecf0;
border-radius: var(--radius);
padding: 12px;
display: flex;
flex-direction: column;
}
.column.done { background: #e3fcef; }
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px 8px;
}
.column-header h2 {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.column-count {
background: rgba(9,30,66,0.08);
color: var(--muted);
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
}
/* ------------------------------------------------------------------ */
/* Cards */
/* ------------------------------------------------------------------ */
.card-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 40px;
}
.card {
background: var(--surface);
border-radius: 6px;
padding: 12px 14px;
box-shadow: var(--card-shadow);
cursor: default;
transition: box-shadow 0.15s ease, transform 0.15s ease;
border-left: 3px solid var(--border);
}
.card:hover {
box-shadow: var(--card-hover);
transform: translateY(-1px);
}
.card.checked {
opacity: 0.65;
text-decoration: line-through;
text-decoration-color: var(--muted);
}
.card.track-it { border-left-color: var(--track-it); }
.card.track-os { border-left-color: var(--track-os); }
.card.track-both { border-left-color: var(--track-both); }
.card .card-meta {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.card .codename {
font-weight: 700;
font-size: 0.9rem;
}
.card .description {
font-size: 0.82rem;
color: var(--muted);
line-height: 1.4;
}
/* ------------------------------------------------------------------ */
/* Notes */
/* ------------------------------------------------------------------ */
.notes {
padding: 20px 32px 32px;
background: var(--surface);
border-top: 1px solid var(--border);
}
.notes h2 {
font-size: 0.95rem;
margin-bottom: 8px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.notes ul {
list-style: disc inside;
color: var(--muted);
font-size: 0.85rem;
display: flex;
flex-direction: column;
gap: 4px;
}
/* ------------------------------------------------------------------ */
/* Footer */
/* ------------------------------------------------------------------ */
footer {
text-align: center;
padding: 16px;
font-size: 0.78rem;
color: var(--muted);
}
footer a {
color: var(--track-it);
text-decoration: none;
}
/* ------------------------------------------------------------------ */
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 900px) {
.board { flex-direction: column; padding: 16px; }
.column { max-width: 100%; }
header, .legend-bar, .legend-table-wrap, .notes { padding-left: 16px; padding-right: 16px; }
}
</style>
</head>
<body>
<!-- ================================================================= -->
<!-- Header -->
<!-- ================================================================= -->
<header>
<h1>
<span class="logo"></span>
Arcline — TODO
</h1>
<p class="subtitle">In-house Kanban board for <strong>Arcline IT</strong> (<a href="https://arcline.it">arcline.it</a>) and <strong>The Arcline Project</strong> (<a href="https://arclineproject.org">arclineproject.org</a>).</p>
</header>
<!-- ================================================================= -->
<!-- Legend bar -->
<!-- ================================================================= -->
<div class="legend-bar">
<span><strong>Track:</strong></span>
<span class="track-badge track-it">IT</span> arcline.it
<span class="track-badge track-os">OS</span> arclineproject.org
<span class="track-badge track-both">IT/OS</span> shared
<span style="margin-left:auto">Move cards between sections as they progress: `Backlog → In Progress → In Review → Done`.</span>
</div>
<!-- ================================================================= -->
<!-- Codename Legend table -->
<!-- ================================================================= -->
<div class="legend-table-wrap">
<h2>Codename Legend</h2>
<table class="legend">
<thead>
<tr>
<th>Codename</th>
<th>Initiative</th>
<th>Track</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Keystone</strong></td>
<td>Customer portal + billing overhaul</td>
<td>
<span class="track-badge track-it">IT</span>
</td>
<td>
<span class="status-pill status-inprogress">In Progress</span>
</td>
</tr>
<tr>
<td><strong>Cornerstone</strong></td>
<td>Arcline OS alpha → public ISO release</td>
<td>
<span class="track-badge track-os">OS</span>
</td>
<td>
<span class="status-pill status-inprogress">In Progress</span>
</td>
</tr>
<tr>
<td><strong>Buttress</strong></td>
<td>`arcline-audit` hardening &amp; CVE scan integration</td>
<td>
<span class="track-badge track-os">OS</span>
</td>
<td>
<span class="status-pill status-backlog">Backlog</span>
</td>
</tr>
<tr>
<td><strong>Voussoir</strong></td>
<td>Migration tool (cPanel/Plesk → Arcline)</td>
<td>
<span class="track-badge track-both">IT/OS</span>
</td>
<td>
<span class="status-pill status-backlog">Backlog</span>
</td>
</tr>
<tr>
<td><strong>Springer</strong></td>
<td>Status page + uptime monitoring rollout</td>
<td>
<span class="track-badge track-it">IT</span>
</td>
<td>
<span class="status-pill status-inreview">In Review</span>
</td>
</tr>
<tr>
<td><strong>Rampart</strong></td>
<td>Firewall/nftables default-deny profile pass</td>
<td>
<span class="track-badge track-os">OS</span>
</td>
<td>
<span class="status-pill status-backlog">Backlog</span>
</td>
</tr>
<tr>
<td><strong>Lintel</strong></td>
<td>Docs site (docs.arcline.it) restructure</td>
<td>
<span class="track-badge track-it">IT</span>
</td>
<td>
<span class="status-pill status-backlog">Backlog</span>
</td>
</tr>
<tr>
<td><strong>Plinth</strong></td>
<td>Referral program launch</td>
<td>
<span class="track-badge track-it">IT</span>
</td>
<td>
<span class="status-pill status-backlog">Backlog</span>
</td>
</tr>
<tr>
<td><strong>Abutment</strong></td>
<td>Workstation (KDE) build target</td>
<td>
<span class="track-badge track-os">OS</span>
</td>
<td>
<span class="status-pill status-backlog">Backlog</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- ================================================================= -->
<!-- Board columns -->
<!-- ================================================================= -->
<div class="board">
<div class="column">
<div class="column-header">
<h2>Backlog</h2>
<span class="column-count">9</span>
</div>
<ul class="card-list">
<li class="card track-os">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
<span class="codename">Buttress</span>
</div>
<p class="description">wire `arcline-audit` into CI for nightly CVE scans</p>
</li>
<li class="card track-os">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
<span class="codename">Rampart</span>
</div>
<p class="description">finalize default-deny nftables ruleset, document opt-in exceptions</p>
</li>
<li class="card track-os">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
<span class="codename">Abutment</span>
</div>
<p class="description">scope KDE Plasma workstation ISO target</p>
</li>
<li class="card track-it">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
<span class="codename">Lintel</span>
</div>
<p class="description">restructure docs.arcline.it into per-tool sections</p>
</li>
<li class="card track-it">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
<span class="codename">Plinth</span>
</div>
<p class="description">build referral program signup + tracking flow</p>
</li>
<li class="card track-both">
<div class="card-meta">
<span class="track-badge track-both">IT/OS</span>
<span class="codename">Voussoir</span>
</div>
<p class="description">spec `arcline-migrate` cPanel import mapping</p>
</li>
<li class="card track-it">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
</div>
<p class="description">Case studies page — draft first 2 client write-ups</p>
</li>
<li class="card track-os">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
</div>
<p class="description">Grafana dashboard pack — ship default node_exporter + Loki dashboards</p>
</li>
<li class="card track-it">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
</div>
<p class="description">Game server plans — add automated backup scheduling to portal</p>
</li>
</ul>
</div>
<div class="column">
<div class="column-header">
<h2>In Progress</h2>
<span class="column-count">4</span>
</div>
<ul class="card-list">
<li class="card track-it">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
<span class="codename">Keystone</span>
</div>
<p class="description">customer portal (`arcline-portal`) SSL + ticketing UI</p>
</li>
<li class="card track-it">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
<span class="codename">Keystone</span>
</div>
<p class="description">Stripe billing (`arcline-billing`) subscription sync</p>
</li>
<li class="card track-os">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
<span class="codename">Cornerstone</span>
</div>
<p class="description">ISO build pipeline (Debian Bookworm base)</p>
</li>
<li class="card track-os">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
<span class="codename">Cornerstone</span>
</div>
<p class="description">btrfs snapshot rollback QA pass</p>
</li>
</ul>
</div>
<div class="column">
<div class="column-header">
<h2>In Review</h2>
<span class="column-count">2</span>
</div>
<ul class="card-list">
<li class="card track-it">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
<span class="codename">Springer</span>
</div>
<p class="description">status page generator (`arcline-status`) — awaiting Blake&#39;s sign-off</p>
</li>
<li class="card track-os">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
</div>
<p class="description">`arcline-dns` — multi-resolver propagation checker — code review open</p>
</li>
</ul>
</div>
<div class="column done">
<div class="column-header">
<h2>Done</h2>
<span class="column-count">4</span>
</div>
<ul class="card-list">
<li class="card track-it checked">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
</div>
<p class="description">Site relaunch — arcline.it copy + pricing page</p>
</li>
<li class="card track-os checked">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
</div>
<p class="description">Site launch — arclineproject.org landing page</p>
</li>
<li class="card track-os checked">
<div class="card-meta">
<span class="track-badge track-os">OS</span>
</div>
<p class="description">Toolchain announcement — 11 Go tools listed publicly</p>
</li>
<li class="card track-it checked">
<div class="card-meta">
<span class="track-badge track-it">IT</span>
</div>
<p class="description">Acquired Saibeo Solutions, onboarded Ed Do as CTO</p>
</li>
</ul>
</div>
</div>
<!-- ================================================================= -->
<!-- Notes -->
<!-- ================================================================= -->
<div class="notes">
<h2>Notes</h2>
<ul>
<li>Keep `[IT]` (arcline.it, revenue-facing) and `[OS]` (arclineproject.org, GPL/open-source)</li>
<li>New codenames go in the legend table before the card is created, not after.</li>
<li>Source repos live on `git.arcline.it` — link commits/PRs in card text once work starts.</li>
</ul>
</div>
<!-- ================================================================= -->
<!-- Footer -->
<!-- ================================================================= -->
<footer>
Built with Python &amp; Jinja2 ·
<a href="https://arcline.it">arcline.it</a> ·
<a href="https://arclineproject.org">arclineproject.org</a>
</footer>
</body>
</html>

1006
templates/board.html Normal file

File diff suppressed because it is too large Load Diff

134
templates/home.html Normal file
View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arcline Kanban</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--border: #dfe1e6;
--text: #172b4d;
--muted: #6b778c;
--accent: #0052cc;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
max-width: 480px;
padding: 48px 32px;
}
.container h1 {
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.container h1 .logo {
background: linear-gradient(135deg, #0052cc, #6554c0);
color: #fff;
width: 36px; height: 36px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
}
.container .subtitle {
color: var(--muted);
margin-bottom: 36px;
font-size: 0.95rem;
}
.board-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.board-link {
display: block;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px 24px;
text-decoration: none;
color: var(--text);
font-weight: 600;
font-size: 1rem;
transition: box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
}
.board-link:hover {
box-shadow: 0 4px 12px rgba(9, 30, 66, 0.12);
transform: translateY(-1px);
border-color: var(--accent);
}
.board-link small {
display: block;
color: var(--muted);
font-weight: 400;
margin-top: 4px;
}
footer {
margin-top: 40px;
font-size: 0.78rem;
color: var(--muted);
}
footer a {
color: var(--accent);
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<h1><span class="logo"></span> Arcline Kanban</h1>
<p class="subtitle">Select a board to get started.</p>
<ul class="board-list">
{% for board in boards %}
<li>
<a class="board-link" href="/{{ board.slug }}">
{{ board.title }}
<small>{{ board.slug }}.todo</small>
</a>
</li>
{% endfor %}
</ul>
<footer>
<a href="https://arcline.it">arcline.it</a> ·
<a href="https://arclineproject.org">arclineproject.org</a>
</footer>
</div>
</body>
</html>