Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ around that is not on this list. See [CONTRIBUTING.md](CONTRIBUTING.md).
- **Delegate & forwarding review, domain-wide.** "Who can read whose mailbox, and whose mail is
leaving the building?" is answerable today only one user at a time, so nobody audits it.
- **Shared drives** — inventory, membership, and the orphaned-drive check before deleting a
departing user.
departing user. Also: moving a folder of mixed ownership into a shared drive as one previewed
Builder recipe — a read that counts what will move (internal) and what must be copied (external),
then `move drivefile … retainsourcefolders duplicatefolders merge` and `copy drivefile … recursive
copysubfilesownedby others duplicatefolders merge` (GAM has no dry run for either).

## Later

Expand Down
2 changes: 1 addition & 1 deletion docs/domains/catalog-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
- `gamgui/core/catalog/models.py` — `CatalogCommand`, `CommandSlot`, `SlotKind`, `Catalog` (sorting/search/`supports_export`).
- `gamgui/core/catalog/describe.py` / `nouns.py` — deterministic fallback `gloss()` when a command has no vendored description.
- `gamgui/web/routes/builder.py` — the `/builder` screen: browse/search, slot assembly (`_assemble`), preview, run, sequence, a read's result table (`/results`) and CSV export, `/pick` type-ahead.
- `gamgui/web/templates/_records_table.html` — a read's result: the filter form, `_records_meta.html` (the "N of M rows" count and the CSV link) and `_records_page.html` (one page of matching rows and the pager).
- `gamgui/web/templates/_records_table.html` — a read's result: the filter form, `_records_meta.html` (the "N of M rows" count and the CSV link) and `_records_page.html` (one page of matching rows and the pager). A cell shows `server.cell(value)` (a list joined, a name dict as the name, another dict as `key: value · …`, never Python's repr), on one line (`truncate whitespace-nowrap max-w-[24rem]`, never `break-all`, which split addresses mid-word), whole in its `title`; `builder.js` expands a cut-off cell on a click with the table's `data-expanded` classes. The CSV export keeps raw values. The screen has no footer and puts its title in the search row, so the working pane gets the height (`h-[calc(100vh-9.5rem)]`); **Hide list** (`#list-toggle`, `aria-expanded`) hides `#catalog-col` and swaps `#builder-grid` to its `data-collapsed-cols`, remembered in `localStorage` (`gamgui.builder.list`, a per-viewer convenience; the page works without it).
- `scripts/build_command_catalog.py` — regenerates the committed browse-only `command_catalog.json` from `GamCommands.txt` on each GAM bump (buildable overlay is NOT here — it lives in `catalog.py`).
- `docs/builder-commands.md` — the fuller domain reference (it points at CLAUDE.md for the counts rather than keeping its own copy).

Expand Down
4 changes: 4 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ and the CSV download takes the filtered rows.
Clicking an address in a result offers follow-ups for that person (delegates, forwarding,
signature, suspend and more). A Builder change only ever runs from its preview.

A wide result? **Hide list** (top right) gives the result the whole width; the Builder remembers
your choice until you click it again ("Show list"). Each value stays on one line: a long one is cut short —
click it to read all of it, or hover. The CSV download always has every value whole.

## Jobs and Stop

Longer runs — a signature rollout, a bulk department change, a CSV of hires, an offboarding, a group
Expand Down
13 changes: 10 additions & 3 deletions gamgui/web/routes/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from ..csvutil import csv_safe
from ..jobs import start_job
from ..previews import TOKEN_FIELD
from ..server import TEMPLATES
from ..server import TEMPLATES, cell
from ._common import NOT_CONNECTED, app_state, as_of, error_partial, friendly

router = APIRouter(prefix="/builder")
Expand Down Expand Up @@ -178,14 +178,21 @@ async def _render_read(request: Request, out: str, cmd, argv, target: str, tenan

def _columns_and_texts(last: dict) -> tuple:
"""Every column any row has, the first row's first (ragged JSON: the table and the CSV show the same
columns), and each row's lowercased text to filter on — worked out once per result."""
columns), and each row's lowercased text to filter on — worked out once per result. The text is what
the table shows (``cell``: a name as "Alice Anders") and, for a structured value, its raw form too,
so a search finds what is on screen and still matches a field name."""
if "texts" not in last:
records = last["records"]
last["cols"] = list(dict.fromkeys(k for r in records for k in r))
last["texts"] = ["\n".join(str(r.get(k, "")) for k in last["cols"]).lower() for r in records]
last["texts"] = ["\n".join(_filter_text(r.get(k, "")) for k in last["cols"]).lower() for r in records]
return last["cols"], last["texts"]


def _filter_text(value) -> str:
shown = cell(value)
return shown if isinstance(value, str) or str(value) == shown else f"{shown}\t{value}"


def _is_internal(address: str, domains: tuple) -> bool:
"""An address at one of the tenant's domains (primary, secondary, domain alias) or a subdomain of one."""
host = address.rsplit("@", 1)[-1]
Expand Down
19 changes: 19 additions & 0 deletions gamgui/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,26 @@ def ago(seconds: Optional[float]) -> str:
return f"{s // 3600} h ago" if s < 86400 else f"{s // 86400} d ago"


def cell(value) -> str:
"""A result value as a person reads it: a structured value GAM returned as JSON (a user's name, a
list of aliases) was shown as Python's repr. Lists join with ", ", a name dict shows the name
(fullName, else given + family), any other dict reads "key: value · key: value". The CSV export
keeps the raw data."""
if value is None:
return ""
if isinstance(value, (list, tuple)):
return ", ".join(cell(v) for v in value)
if isinstance(value, dict):
if value.get("fullName"):
return str(value["fullName"])
if value.get("givenName") or value.get("familyName"): # a name without its fullName
return " ".join(str(value[k]) for k in ("givenName", "familyName") if value.get(k))
return " · ".join(f"{k}: {cell(v)}" for k, v in value.items())
return str(value)


TEMPLATES.env.filters["ago"] = ago
TEMPLATES.env.filters["cell"] = cell
TEMPLATES.env.globals["jobs_tray"] = _jobs_tray
TEMPLATES.env.globals["active_tenant"] = _active_tenant
TOKEN_COOKIE = "gamgui_token" # noqa: S105 — a cookie name, not a secret
Expand Down
20 changes: 18 additions & 2 deletions gamgui/web/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -874,8 +874,8 @@ video {
height: 11rem;
}

.h-\[calc\(100vh-14\.5rem\)\] {
height: calc(100vh - 14.5rem);
.h-\[calc\(100vh-9\.5rem\)\] {
height: calc(100vh - 9.5rem);
}

.h-\[calc\(100vh-9rem\)\] {
Expand Down Expand Up @@ -1038,6 +1038,10 @@ video {
min-width: 16rem;
}

.min-w-\[24rem\] {
min-width: 24rem;
}

.max-w-0 {
max-width: 0px;
}
Expand All @@ -1054,6 +1058,10 @@ video {
max-width: 13rem;
}

.max-w-\[24rem\] {
max-width: 24rem;
}

.max-w-none {
max-width: none;
}
Expand Down Expand Up @@ -1301,6 +1309,10 @@ video {
white-space: nowrap;
}

.whitespace-normal {
white-space: normal;
}

.whitespace-nowrap {
white-space: nowrap;
}
Expand Down Expand Up @@ -2409,4 +2421,8 @@ video {
.lg\:grid-cols-\[22rem_minmax\(0\2c 1fr\)\] {
grid-template-columns: 22rem minmax(0,1fr);
}

.lg\:grid-cols-\[minmax\(0\2c 1fr\)\] {
grid-template-columns: minmax(0,1fr);
}
}
31 changes: 31 additions & 0 deletions gamgui/web/static/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,37 @@
(function () {
"use strict";

// Hide/show the command list: a wide result then gets the whole width. Remembered per viewer (a
// convenience: storage can be blocked, and the page works the same without it).
function setList(shown) {
var layout = document.getElementById("builder-grid");
var col = document.getElementById("catalog-col");
var btn = document.getElementById("list-toggle");
if (!layout || !col || !btn) return;
col.classList.toggle("hidden", !shown);
layout.classList.toggle("lg:grid-cols-[22rem_minmax(0,1fr)]", shown);
layout.classList.toggle(layout.dataset.collapsedCols, !shown);
btn.setAttribute("aria-expanded", shown ? "true" : "false");
btn.textContent = shown ? "Hide list" : "Show list";
try { localStorage.setItem("gamgui.builder.list", shown ? "shown" : "hidden"); } catch (e) { /* blocked */ }
}
GamGUI.actions["builder-list"] = function (btn) { setList(btn.getAttribute("aria-expanded") !== "true"); };

// A result cell cut short (one line, ellipsis) shows all of itself on a click, and folds back on
// another. Its title carries the whole value too, and the CSV export keeps every value whole.
document.addEventListener("click", function (e) {
var td = e.target.closest && e.target.closest("#builder-result td[title]");
if (!td || e.target.closest("button")) return;
var table = td.closest("table");
var wide = (table && table.dataset.expanded || "").split(" ").filter(Boolean);
var open = td.classList.contains("truncate");
if (open && td.scrollWidth <= td.clientWidth) return; // nothing hidden: leave it
td.classList.toggle("truncate", !open);
td.classList.toggle("whitespace-nowrap", !open);
wide.forEach(function (c) { td.classList.toggle(c, open); });
});
try { if (localStorage.getItem("gamgui.builder.list") === "hidden") setList(false); } catch (e) { /* blocked */ }

// After a Preview swaps the confirm panel into the result area, scroll the build pane so its
// Run button is in reach (the pane scrolls as one unit, so a tall form can't leave Run below the
// fold). Only when an actionable Run/confirm is present — not for a plain results table.
Expand Down
8 changes: 5 additions & 3 deletions gamgui/web/templates/_records_page.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
{# One page of a read's matching rows and its pager (_records_table.html holds the filter). #}
{% if oob %}{% include "_records_meta.html" %}{% endif %}
{# A cut-off value expands in place on a click (builder.js swaps in data-expanded's classes). #}
<div class="overflow-x-auto">
<table class="w-full text-xs">
<table class="w-full text-xs" data-expanded="whitespace-normal break-words min-w-[24rem]">
<thead><tr class="text-left text-brand-grayink">{% for k in cols %}<th scope="col" class="px-2 py-1">{{ k }}</th>{% endfor %}</tr></thead>
<tbody>
{% for r in rows %}
<tr class="border-t border-brand-gray/15">{% for k in cols %}{% set v = r.get(k, "") | string %}
<td class="px-2 py-1 align-top break-all">{% if '@' in v and '.' in v and ' ' not in v %}<button type="button" class="cell-act text-brand-blueink underline decoration-dotted decoration-brand-gray/50 underline-offset-2 hover:text-brand-black" data-email="{{ v }}" title="Act on {{ v }}">{{ v }}</button>{% else %}{{ v }}{% endif %}</td>{% endfor %}</tr>
{# A value never breaks mid-word; a long one is cut short, whole in its tooltip (and the CSV). #}
<tr class="border-t border-brand-gray/15">{% for k in cols %}{% set v = r.get(k, "") | cell %}
<td class="px-2 py-1 align-top max-w-[24rem] truncate whitespace-nowrap" title="{{ v }}">{% if '@' in v and '.' in v and ' ' not in v %}<button type="button" class="cell-act text-brand-blueink underline decoration-dotted decoration-brand-gray/50 underline-offset-2 hover:text-brand-black" data-email="{{ v }}" title="Act on {{ v }}">{{ v }}</button>{% else %}{{ v }}{% endif %}</td>{% endfor %}</tr>
{% else %}
<tr><td colspan="{{ cols | length }}" class="px-2 py-4 text-center text-brand-grayink">No row of the {{ "{:,}".format(total) }}{% if q.strip() %} contains “{{ q.strip() }}”{% endif %}{% if external %}{{ " and" if q.strip() }} names an address outside your domains{% endif %}.</td></tr>
{% endfor %}
Expand Down
4 changes: 2 additions & 2 deletions gamgui/web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ <h2 id="jobs-heading" class="eyebrow text-brand-blue">Background jobs</h2>
<main class="mx-auto {% block container_class %}max-w-5xl{% endblock %} px-6 py-10">
{% block content %}{% endblock %}
</main>
<footer class="mx-auto {{ self.container_class() }} px-6 py-8 text-xs text-brand-grayink">
{% block footer %}<footer class="mx-auto {{ self.container_class() }} px-6 py-8 text-xs text-brand-grayink">
GamGUI — a local admin console for Google Workspace
</footer>
</footer>{% endblock %}
</div>
<!-- Every script is a same-origin file: the CSP allows no inline script (server.py). -->
<script src="/static/app.js"></script>
Expand Down
26 changes: 14 additions & 12 deletions gamgui/web/templates/builder.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{% extends "base.html" %}
{% block title %}Builder — GamGUI Console{% endblock %}
{% block container_class %}max-w-none{% endblock %}
{# A fixed-height workspace: the footer line's height goes to the results instead. #}
{% block footer %}{% endblock %}
{% block content %}
{% if not connected %}
<div class="rounded-2xl border border-brand-gray/40 bg-brand-white p-6 shadow-sm">
Expand All @@ -10,16 +12,12 @@ <h1 class="mt-2 font-serif text-2xl text-brand-black">Connect a domain first</h1
</div>
{% else %}
{% set fld = "w-full rounded-lg border border-brand-field bg-brand-white px-3 py-2 text-sm text-ink placeholder:text-brand-grayink focus:border-brand-blue focus:outline-none focus:ring-2 focus:ring-brand-blue/30" %}
<!-- Fixed-height workspace: fills the window and never grows. Results scroll inside their own
panel instead of pushing the page down, so the window stays a constant size. -->
<div id="builder-root" class="flex flex-col gap-2.5 h-[calc(100vh-14.5rem)] min-h-[26rem]">
<div class="flex items-baseline gap-3 shrink-0">
<h1 class="font-serif text-xl text-brand-black tracking-tight">Command builder</h1>
<p class="text-xs text-brand-grayink">Browse every GAM command; build &amp; run the buildable ones, or queue a sequence.</p>
</div>

<!-- Search-first faceted controls: search · area · buildable-only (one row, one request) -->
<!-- Fixed-height workspace: fills the window (header and main's padding aside: no footer here) and
never grows. Results scroll inside their own panel instead of pushing the page down. -->
<div id="builder-root" class="flex flex-col gap-2.5 h-[calc(100vh-9.5rem)] min-h-[26rem]">
<!-- One row: the title, then search · area · buildable-only (one request), then the list toggle -->
<form id="cat-controls" class="flex items-center gap-2 shrink-0">
<h1 class="shrink-0 font-serif text-xl text-brand-black tracking-tight">Command builder</h1>
<input class="{{ fld }} min-w-0 flex-1" type="search" name="q" aria-label="Search commands" placeholder="Search 1,000+ commands…"
hx-get="/builder/catalog" hx-include="#cat-controls" hx-target="#catalog" hx-swap="innerHTML"
hx-trigger="keyup changed delay:300ms, search" />
Expand All @@ -33,11 +31,15 @@ <h1 class="font-serif text-xl text-brand-black tracking-tight">Command builder</
hx-get="/builder/catalog" hx-include="#cat-controls" hx-target="#catalog" hx-swap="innerHTML" />
Buildable only
</label>
<!-- Hide the command list so a wide result gets the whole width (remembered per viewer, builder.js). -->
<button type="button" id="list-toggle" data-action="builder-list" aria-controls="catalog-col" aria-expanded="true"
class="shrink-0 rounded-lg border border-brand-gray/40 px-3 py-2 text-sm text-brand-blueink hover:bg-paper">Hide list</button>
</form>

<div class="grid gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] min-h-0 flex-1">
<div id="builder-grid" class="grid gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] min-h-0 flex-1"
data-collapsed-cols="lg:grid-cols-[minmax(0,1fr)]">
<!-- LEFT: the paginated catalog (one page at a time, fills its column) -->
<section class="min-h-0">
<section id="catalog-col" class="min-h-0">
<div id="catalog" class="h-full overflow-y-auto rounded-2xl border border-brand-gray/40 bg-brand-white p-3 shadow-sm"
hx-get="/builder/catalog?buildable=1" hx-trigger="load" hx-swap="innerHTML">
<p class="text-sm text-brand-grayink">Loading…</p>
Expand All @@ -49,7 +51,7 @@ <h1 class="font-serif text-xl text-brand-black tracking-tight">Command builder</
<section id="build-pane" class="min-h-0 overflow-y-auto pr-0.5">
<div id="cmd-form">
<div class="rounded-2xl border border-dashed border-brand-gray/40 bg-brand-white/50 p-6 text-sm text-brand-grayink">
Pick a command on the left and click <strong class="text-brand-blueink">Build</strong> — its form opens here. Click a User field to choose someone.
Browse every GAM command; build &amp; run the buildable ones, or queue a sequence. Pick a command on the left and click <strong class="text-brand-blueink">Build</strong> — its form opens here. Click a User field to choose someone.
</div>
</div>
<div id="builder-result" class="mt-3"></div>
Expand Down
41 changes: 41 additions & 0 deletions tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,47 @@ def test_curated_search_commands_run(client):
assert rf.status_code == 200 and "Q4 Budget" in rf.text # a mock file name


@pytest.mark.parametrize(("value", "shown"), [
(None, ""), (True, "True"), (3, "3"), ("a@example.com", "a@example.com"),
(["a@example.com", "b@example.com"], "a@example.com, b@example.com"),
({"givenName": "Ada", "familyName": "Byte", "fullName": "Ada Byte"}, "Ada Byte"),
({"givenName": "Ada", "familyName": "Byte"}, "Ada Byte"),
({"type": "work", "value": "123"}, "type: work · value: 123"),
([{"address": "a@example.com", "primary": True}], "address: a@example.com · primary: True"),
])
def test_a_result_cell_reads_as_text_not_python(value, shown):
from gamgui.web.server import cell
assert cell(value) == shown


def test_the_builder_gives_results_the_room_the_footer_and_title_row_took(client):
page = client.get("/builder").text
assert "<footer" not in page # a fixed workspace: no footer line
controls = page.split('id="cat-controls"')[1].split("</form>")[0]
assert "Command builder" in controls # the title shares the search row
toggle = controls.split('id="list-toggle"')[1].split("</button>")[0]
assert 'aria-controls="catalog-col"' in toggle and 'aria-expanded="true"' in toggle and "Hide list" in toggle
assert 'id="catalog-col"' in page and 'data-collapsed-cols="lg:grid-cols-[minmax(0,1fr)]"' in page
assert "<footer" in client.get("/audit").text # other screens keep it


def test_result_cells_never_break_mid_word_and_carry_their_whole_value(client):
r = client.post("/builder/run", data={"cid": "build.find_users", "query": "isSuspended=true"})
cells = re.findall(r'<td class="([^"]*)" title="([^"]*)"', r.text)
assert cells and all("break-all" not in c and "truncate" in c and "whitespace-nowrap" in c for c, _ in cells)
assert not any(t.startswith(("{", "[")) for _, t in cells) # structured values read as text
assert 'data-expanded="whitespace-normal break-words min-w-[24rem]"' in r.text # a click expands a cut-off value


def test_the_row_filter_finds_what_the_table_shows_and_still_the_raw_field(client):
r = client.post("/builder/run", data={"cid": "build.find_users", "query": "isSuspended=true"})
rid = re.search(r'name="rid" value="([^"]+)"', r.text).group(1)
shown = client.get("/builder/results", params={"rid": rid, "q": "Alice Anders"}).text
assert "alice@example.com" in shown # the name as displayed
raw = client.get("/builder/results", params={"rid": rid, "q": "familyName"}).text
assert "alice@example.com" in raw # a raw field name still matches


def test_export_offered_exactly_for_todrive_reads(client):
# Export-to-Sheet shows iff the GAM command actually supports `todrive`.
cat = load_catalog()
Expand Down
Loading