feat(web): operator-owned Support callout in welcome hero
New `instance.support` (`AGNES_INSTANCE_SUPPORT` env override) config field renders operator-authored HTML in a mint-accent callout panel inside the welcome hero on /home, below the Overview footnotes. Designed for a one-line invitation pointing at a chat space, mailing list, or runbook so every user knows where to ask for help. - `get_instance_support()` helper mirrors `get_instance_overview()` (env > yaml > "" resolution, `| safe` filter trust boundary). - Wired into the home template context as `config.INSTANCE_SUPPORT`. - Template renders the callout inside the welcome hero, after the Overview footnotes block — empty yaml hides the block so the OSS stays vendor-neutral. - Registered in `_KNOWN_FIELDS["instance"]` so the field appears in `/admin/server-config` as "Available but unset" even before the operator populates it (discoverability for first-time setup). - 4 new tests cover the gated render path, the hidden-when-unset path, and independence from `instance.overview`. Operators who want to fill the block via terraform write the body to `modules/.../assets/support.html` in their infra repo and include it in the startup.sh yaml heredoc — the OSS template treats this as one more `| safe`-rendered field, no other plumbing needed.
This commit is contained in:
parent
19eef69092
commit
3167d37a56
7 changed files with 196 additions and 7 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -11,6 +11,17 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `instance.support`: operator-authored HTML body rendered in a
|
||||
mint-accent callout panel inside the welcome hero on `/home`,
|
||||
below the Overview footnotes. Designed for a one-line invitation
|
||||
pointing analysts at a chat space, mailing list, or runbook so
|
||||
every user sees where to ask for help. HTML in, HTML out (same
|
||||
`| safe` filter as `instance.overview`); empty default keeps the
|
||||
OSS vendor-neutral. Resolved by
|
||||
`app/instance_config.py::get_instance_support()`; surfaced in
|
||||
`/admin/server-config` via `_KNOWN_FIELDS["instance"]` so it
|
||||
appears as "Available but unset" for operators who haven't
|
||||
populated it yet. Env override: `AGNES_INSTANCE_SUPPORT`.
|
||||
- `instance.custom_scripts`: operator-injected HTML/JS blocks rendered
|
||||
into every page that extends `base.html`. Each entry takes `name`,
|
||||
`enabled`, `placement` (`head_start` | `head_end` | `body_end`), and
|
||||
|
|
|
|||
|
|
@ -325,6 +325,26 @@ _KNOWN_FIELDS: dict[str, dict[str, dict]] = {
|
|||
"capture session data). Restart required after save."
|
||||
),
|
||||
},
|
||||
# Operator-authored Support HTML rendered inside the welcome
|
||||
# hero on /home, below the operator-owned Overview footnotes.
|
||||
# Resolved by `app/instance_config.py::get_instance_support()`.
|
||||
# Typical content: a one-line invitation pointing at a chat
|
||||
# space, mailing list, or internal runbook. Empty value =
|
||||
# block hidden (OSS stays vendor-neutral).
|
||||
"support": {
|
||||
"kind": "string",
|
||||
"hint": (
|
||||
"HTML body rendered inside the welcome hero's Support "
|
||||
"block on /home (mint-accent panel below the Overview "
|
||||
"footnotes). Typically a one-line invitation linking to "
|
||||
"a chat space, mailing list, or runbook — e.g. "
|
||||
"'<p><strong>Need help?</strong> Drop into our "
|
||||
"<a href=\"https://chat.example.com/room/xxx\">Support</a> "
|
||||
"chat space.</p>'. Rendered with | safe — admin trust "
|
||||
"boundary (link target is operator-controlled). Empty "
|
||||
"value hides the block."
|
||||
),
|
||||
},
|
||||
},
|
||||
"data_source": {
|
||||
"bigquery": {
|
||||
|
|
|
|||
|
|
@ -365,6 +365,28 @@ def get_instance_overview() -> str:
|
|||
return (raw or "").strip()
|
||||
|
||||
|
||||
def get_instance_support() -> str:
|
||||
"""Operator-authored Support body rendered inside the welcome hero
|
||||
on ``/home``. Same ``| safe``-filter shape as
|
||||
:func:`get_instance_overview` — operators paste HTML. Distinct
|
||||
config field so help/chat pointers can be updated independently
|
||||
from the product framing in ``instance.overview``.
|
||||
|
||||
Typical content: a one-line invitation pointing at a chat space
|
||||
(Google Chat / Slack / Teams), a mailing list, or an internal
|
||||
runbook. Empty default = block hidden, keeping the OSS
|
||||
vendor-neutral when an instance ships without an operator-defined
|
||||
support channel.
|
||||
|
||||
Resolution: ``AGNES_INSTANCE_SUPPORT`` env > ``instance.support``
|
||||
YAML > ``""``. Mirrors :func:`get_instance_overview`.
|
||||
"""
|
||||
raw = os.environ.get("AGNES_INSTANCE_SUPPORT")
|
||||
if raw is None:
|
||||
raw = get_value("instance", "support", default="")
|
||||
return (raw or "").strip()
|
||||
|
||||
|
||||
_CUSTOM_SCRIPT_PLACEMENTS = ("head_start", "head_end", "body_end")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from app.instance_config import (
|
|||
get_gws_oauth_credentials, get_home_automode_visibility,
|
||||
get_instance_admin_email, get_atlassian_base_url,
|
||||
get_instance_brand, get_workspace_dir_name,
|
||||
get_instance_logo_svg, get_instance_overview,
|
||||
get_instance_logo_svg, get_instance_overview, get_instance_support,
|
||||
get_instance_theme, get_custom_scripts,
|
||||
)
|
||||
from app.web.connector_prompts import all_connector_prompts
|
||||
|
|
@ -378,6 +378,7 @@ def _build_context(
|
|||
INSTANCE_COPYRIGHT = ""
|
||||
LOGO_SVG = get_instance_logo_svg()
|
||||
INSTANCE_OVERVIEW = get_instance_overview()
|
||||
INSTANCE_SUPPORT = get_instance_support()
|
||||
TELEGRAM_BOT_USERNAME = os.environ.get("TELEGRAM_BOT_USERNAME", "")
|
||||
SSH_ALIAS = "data-analyst"
|
||||
SERVER_HOST = os.environ.get("SERVER_HOST", "")
|
||||
|
|
|
|||
|
|
@ -1131,6 +1131,59 @@
|
|||
font-family: var(--ds-font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
/* Support callout — operator-owned panel rendered inside the welcome
|
||||
hero, below the Overview footnotes. Mint-tint background + brand-
|
||||
accent border reads as a complementary help affordance (distinct
|
||||
from the flat hairline-separated footnotes above it). Speech-bubble
|
||||
icon on the left, operator HTML on the right. */
|
||||
.home-mock .home-hero-support {
|
||||
margin-top: 22px;
|
||||
padding: 16px 20px;
|
||||
background: rgba(84, 211, 160, 0.10);
|
||||
border: 1px solid rgba(84, 211, 160, 0.28);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.home-mock .home-hero-support-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
color: var(--ds-brand-accent);
|
||||
}
|
||||
.home-mock .home-hero-support-body {
|
||||
font-size: 13.5px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
line-height: 1.55;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.home-mock .home-hero-support-body p { margin: 0 0 8px; }
|
||||
.home-mock .home-hero-support-body p:last-child { margin-bottom: 0; }
|
||||
.home-mock .home-hero-support-body strong { color: #ffffff; font-weight: 600; }
|
||||
.home-mock .home-hero-support-body a {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--ds-brand-accent);
|
||||
text-underline-offset: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.home-mock .home-hero-support-body a:hover,
|
||||
.home-mock .home-hero-support-body a:focus {
|
||||
text-decoration-color: #ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
.home-mock .home-hero-support-body code {
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
color: #FBBF24;
|
||||
border: 1px solid rgba(251, 191, 36, 0.30);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--ds-font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.home-mock .home-hero-intro { padding: 32px 24px 28px; }
|
||||
.home-mock .home-hero-intro h1 { font-size: 26px; }
|
||||
|
|
@ -2310,6 +2363,20 @@
|
|||
{% if config.INSTANCE_OVERVIEW %}
|
||||
<div class="home-hero-footnotes">{{ config.INSTANCE_OVERVIEW | safe }}</div>
|
||||
{% endif %}
|
||||
{# Support callout — operator-owned, opt-in. Separate
|
||||
`instance.support` (`AGNES_INSTANCE_SUPPORT` env override)
|
||||
field so help/chat pointers can be updated independently
|
||||
from the product framing in `instance.overview`. Body is
|
||||
operator-authored HTML rendered through the same | safe
|
||||
filter — typical content is a one-line invitation linking
|
||||
a chat space, mailing list, or runbook. Empty value →
|
||||
block hidden, keeping the OSS vendor-neutral. #}
|
||||
{% if config.INSTANCE_SUPPORT %}
|
||||
<div class="home-hero-support" role="note" aria-label="Support">
|
||||
<div class="home-hero-support-icon" aria-hidden="true">💬</div>
|
||||
<div class="home-hero-support-body">{{ config.INSTANCE_SUPPORT | safe }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Homepage status frame — five counters with 24h/7d toggle.
|
||||
|
|
|
|||
|
|
@ -36,14 +36,23 @@ instance:
|
|||
# # `name` above still drives browser <title> text and page
|
||||
# # headings — keep it populated. Env override:
|
||||
# # AGNES_INSTANCE_LOGO_SVG.
|
||||
# overview: | # Operator-authored Overview body rendered in the new
|
||||
# overview: | # Operator-authored Overview body rendered inside the
|
||||
# <p>Free-form HTML — paragraphs, links, lists.</p>
|
||||
# # Overview section on /home (between Getting Started and
|
||||
# # Usage modes). Use for product framing, privacy posture,
|
||||
# # what-data-flows summary — operator-specific copy stays
|
||||
# # out of the OSS this way. HTML in, HTML out (same `| safe`
|
||||
# # filter as news_intro). Empty/unset = section hidden.
|
||||
# # welcome hero on /home (hairline-separated footnotes
|
||||
# # below the four-pillar row). Use for product framing,
|
||||
# # privacy posture, what-data-flows summary —
|
||||
# # operator-specific copy stays out of the OSS this way.
|
||||
# # HTML in, HTML out (same `| safe` filter as news_intro).
|
||||
# # Empty/unset = block hidden.
|
||||
# # Env override: AGNES_INSTANCE_OVERVIEW.
|
||||
# support: | # Operator-authored Support body rendered in a
|
||||
# <p><strong>Need help?</strong> Drop into our <a href="...">Support</a> chat space.</p>
|
||||
# # mint-accent panel inside the welcome hero on /home
|
||||
# # (below the Overview footnotes). Use to point analysts
|
||||
# # at a chat space, mailing list, or runbook so they know
|
||||
# # where to ask for help. HTML in, HTML out (same `| safe`
|
||||
# # filter as overview). Empty/unset = block hidden.
|
||||
# # Env override: AGNES_INSTANCE_SUPPORT.
|
||||
# sync_interval: "1 hour" # Cadence shown in analyst CLAUDE.md (e.g., "1 hour", "30 minutes", "daily")
|
||||
# admin_email: "ops@acme.com" # Operator contact shown on /home GWS connector tile as
|
||||
# an "Email admin" mailto button (analysts whose operator
|
||||
|
|
|
|||
|
|
@ -398,3 +398,62 @@ def test_welcome_footnotes_hidden_when_overview_unset(fresh_db, monkeypatch):
|
|||
close_system_db()
|
||||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||||
assert '<div class="home-hero-footnotes">' not in body
|
||||
|
||||
|
||||
def test_welcome_support_renders_when_set(fresh_db, monkeypatch):
|
||||
"""Setting `AGNES_INSTANCE_SUPPORT` (mirrors `instance.support`
|
||||
yaml) injects raw HTML into the mint-accent Support callout
|
||||
inside the welcome hero. The marker text MUST appear inside
|
||||
`.home-hero-support-body`. Separate field from
|
||||
`instance.overview` so support/help pointers can be updated
|
||||
independently from the operator's product framing."""
|
||||
monkeypatch.setenv("AGNES_INSTANCE_SUPPORT", "<p>SUPPORT_TEST_MARKER</p>")
|
||||
from src.db import get_system_db, close_system_db
|
||||
|
||||
conn = get_system_db()
|
||||
try:
|
||||
_, sess = _make_user_and_session(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
close_system_db()
|
||||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||||
assert '<div class="home-hero-support"' in body
|
||||
assert "SUPPORT_TEST_MARKER" in body
|
||||
|
||||
|
||||
def test_welcome_support_hidden_when_unset(fresh_db, monkeypatch):
|
||||
"""Default empty `instance.support` (no env override) hides the
|
||||
Support callout entirely so the OSS ships without a stray
|
||||
empty mint panel in the welcome card."""
|
||||
monkeypatch.delenv("AGNES_INSTANCE_SUPPORT", raising=False)
|
||||
from src.db import get_system_db, close_system_db
|
||||
|
||||
conn = get_system_db()
|
||||
try:
|
||||
_, sess = _make_user_and_session(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
close_system_db()
|
||||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||||
assert '<div class="home-hero-support"' not in body
|
||||
|
||||
|
||||
def test_welcome_support_independent_of_overview(fresh_db, monkeypatch):
|
||||
"""The Support callout MUST render even when `instance.overview`
|
||||
is empty — the two fields are independent. Catches a regression
|
||||
where the Support gate was accidentally wired to
|
||||
INSTANCE_OVERVIEW instead of INSTANCE_SUPPORT."""
|
||||
monkeypatch.delenv("AGNES_INSTANCE_OVERVIEW", raising=False)
|
||||
monkeypatch.setenv("AGNES_INSTANCE_SUPPORT", "<p>SUPPORT_ONLY_MARKER</p>")
|
||||
from src.db import get_system_db, close_system_db
|
||||
|
||||
conn = get_system_db()
|
||||
try:
|
||||
_, sess = _make_user_and_session(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
close_system_db()
|
||||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||||
assert '<div class="home-hero-footnotes">' not in body
|
||||
assert '<div class="home-hero-support"' in body
|
||||
assert "SUPPORT_ONLY_MARKER" in body
|
||||
|
|
|
|||
Loading…
Reference in a new issue