Merge pull request #384 from keboola/dr/homepage-small-changes

feat(web): operator-owned Support callout in welcome hero
This commit is contained in:
David Rybar 2026-05-22 14:35:29 +02:00 committed by GitHub
commit 9dbe36fc38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 196 additions and 7 deletions

View file

@ -11,6 +11,17 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased] ## [Unreleased]
### Added ### 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 - `instance.custom_scripts`: operator-injected HTML/JS blocks rendered
into every page that extends `base.html`. Each entry takes `name`, into every page that extends `base.html`. Each entry takes `name`,
`enabled`, `placement` (`head_start` | `head_end` | `body_end`), and `enabled`, `placement` (`head_start` | `head_end` | `body_end`), and

View file

@ -325,6 +325,26 @@ _KNOWN_FIELDS: dict[str, dict[str, dict]] = {
"capture session data). Restart required after save." "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": { "data_source": {
"bigquery": { "bigquery": {

View file

@ -365,6 +365,28 @@ def get_instance_overview() -> str:
return (raw or "").strip() 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") _CUSTOM_SCRIPT_PLACEMENTS = ("head_start", "head_end", "body_end")

View file

@ -25,7 +25,7 @@ from app.instance_config import (
get_gws_oauth_credentials, get_home_automode_visibility, get_gws_oauth_credentials, get_home_automode_visibility,
get_instance_admin_email, get_atlassian_base_url, get_instance_admin_email, get_atlassian_base_url,
get_instance_brand, get_workspace_dir_name, 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, get_instance_theme, get_custom_scripts,
) )
from app.web.connector_prompts import all_connector_prompts from app.web.connector_prompts import all_connector_prompts
@ -378,6 +378,7 @@ def _build_context(
INSTANCE_COPYRIGHT = "" INSTANCE_COPYRIGHT = ""
LOGO_SVG = get_instance_logo_svg() LOGO_SVG = get_instance_logo_svg()
INSTANCE_OVERVIEW = get_instance_overview() INSTANCE_OVERVIEW = get_instance_overview()
INSTANCE_SUPPORT = get_instance_support()
TELEGRAM_BOT_USERNAME = os.environ.get("TELEGRAM_BOT_USERNAME", "") TELEGRAM_BOT_USERNAME = os.environ.get("TELEGRAM_BOT_USERNAME", "")
SSH_ALIAS = "data-analyst" SSH_ALIAS = "data-analyst"
SERVER_HOST = os.environ.get("SERVER_HOST", "") SERVER_HOST = os.environ.get("SERVER_HOST", "")

View file

@ -1131,6 +1131,59 @@
font-family: var(--ds-font-mono); font-family: var(--ds-font-mono);
font-size: 11.5px; 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) { @media (max-width: 880px) {
.home-mock .home-hero-intro { padding: 32px 24px 28px; } .home-mock .home-hero-intro { padding: 32px 24px 28px; }
.home-mock .home-hero-intro h1 { font-size: 26px; } .home-mock .home-hero-intro h1 { font-size: 26px; }
@ -2310,6 +2363,20 @@
{% if config.INSTANCE_OVERVIEW %} {% if config.INSTANCE_OVERVIEW %}
<div class="home-hero-footnotes">{{ config.INSTANCE_OVERVIEW | safe }}</div> <div class="home-hero-footnotes">{{ config.INSTANCE_OVERVIEW | safe }}</div>
{% endif %} {% 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">&#x1F4AC;</div>
<div class="home-hero-support-body">{{ config.INSTANCE_SUPPORT | safe }}</div>
</div>
{% endif %}
</section> </section>
{# Homepage status frame — five counters with 24h/7d toggle. {# Homepage status frame — five counters with 24h/7d toggle.

View file

@ -36,14 +36,23 @@ instance:
# # `name` above still drives browser <title> text and page # # `name` above still drives browser <title> text and page
# # headings — keep it populated. Env override: # # headings — keep it populated. Env override:
# # AGNES_INSTANCE_LOGO_SVG. # # 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> # <p>Free-form HTML — paragraphs, links, lists.</p>
# # Overview section on /home (between Getting Started and # # welcome hero on /home (hairline-separated footnotes
# # Usage modes). Use for product framing, privacy posture, # # below the four-pillar row). Use for product framing,
# # what-data-flows summary — operator-specific copy stays # # privacy posture, what-data-flows summary —
# # out of the OSS this way. HTML in, HTML out (same `| safe` # # operator-specific copy stays out of the OSS this way.
# # filter as news_intro). Empty/unset = section hidden. # # HTML in, HTML out (same `| safe` filter as news_intro).
# # Empty/unset = block hidden.
# # Env override: AGNES_INSTANCE_OVERVIEW. # # 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") # 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 # admin_email: "ops@acme.com" # Operator contact shown on /home GWS connector tile as
# an "Email admin" mailto button (analysts whose operator # an "Email admin" mailto button (analysts whose operator

View file

@ -398,3 +398,62 @@ def test_welcome_footnotes_hidden_when_overview_unset(fresh_db, monkeypatch):
close_system_db() close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text body = _client().get("/home", cookies={"access_token": sess}).text
assert '<div class="home-hero-footnotes">' not in body 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