Initial commit: Shelly Manager with Textual CLI, Streamlit UI, and comprehensive .gitignore
Shelly device management app with mDNS/subnet discovery, inventory, configuration, and mass operations for Gen1/Gen2+ devices. Includes .gitignore excluding runtime data (device DB, user config), AI conversation history, build artifacts, and common Python/OS patterns.
This commit is contained in:
@@ -0,0 +1,220 @@
|
|||||||
|
---
|
||||||
|
name: developing-with-streamlit
|
||||||
|
description: "**[REQUIRED]** Use for ALL Streamlit tasks: creating, editing, debugging, beautifying, styling, theming, optimizing, or deploying Streamlit applications. Also required for building custom components (inline or packaged), using st.components.v2, or any HTML/JS/CSS component work. Triggers: streamlit, st., dashboard, app.py, beautify, style, CSS, color, background, theme, button, widget styling, custom component, st.components, packaged component, pyproject.toml, asset_dir, CCv2, HTML/JS component."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Developing with Streamlit
|
||||||
|
|
||||||
|
This is a **routing skill** that directs you to specialized sub-skills for Streamlit development.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Invoke this skill when the user's request involves:
|
||||||
|
- Creating a new Streamlit app
|
||||||
|
- Editing or modifying an existing Streamlit app
|
||||||
|
- Debugging Streamlit issues (errors, session state bugs, performance problems)
|
||||||
|
- Beautifying or improving the visual design of a Streamlit app
|
||||||
|
- Optimizing Streamlit performance (caching, fragments, reruns)
|
||||||
|
- Deploying Streamlit apps (locally or to Snowflake)
|
||||||
|
- Styling widgets (button colors, backgrounds, CSS customization)
|
||||||
|
- Any question about Streamlit widgets, layouts, or components
|
||||||
|
|
||||||
|
**Trigger phrases:** "streamlit", "st.", "dashboard", "app.py", "beautify app", "make it look better", "style", "CSS", "color", "background", "theme", "button", "slow rerun", "session state", "performance", "faster", "cache", "deploy"
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Locate the Streamlit source code
|
||||||
|
↓
|
||||||
|
Step 2: Identify task type and load appropriate sub-skill(s)
|
||||||
|
↓
|
||||||
|
Step 3: Apply guidance from sub-skill to edit code
|
||||||
|
↓
|
||||||
|
Step 4: Check if app is running and offer to run it
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1: Locate the Streamlit Source Code (if needed)
|
||||||
|
|
||||||
|
**Goal:** Identify the app file(s) to edit. **Skip this step if already clear from context.**
|
||||||
|
|
||||||
|
**When to skip:**
|
||||||
|
- User mentioned a specific file path (e.g., "edit `src/app.py`")
|
||||||
|
- User has file(s) already in conversation context
|
||||||
|
- Working directory has an obvious single entry point (`app.py`, `streamlit_app.py`)
|
||||||
|
|
||||||
|
**When to search:**
|
||||||
|
- User says "my streamlit app" without specifying which file
|
||||||
|
- Multiple Python files exist and it's unclear which is the entry point
|
||||||
|
|
||||||
|
**If searching is needed:**
|
||||||
|
|
||||||
|
1. **Quick scan** for Streamlit files:
|
||||||
|
```bash
|
||||||
|
find . -name "*.py" -type f | xargs grep -l "import streamlit\|from streamlit" 2>/dev/null | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Apply entry point heuristics** (in priority order):
|
||||||
|
- `streamlit_app.py` at root → **this is the entry point** (canonical name)
|
||||||
|
- `app.py` at root → likely entry point
|
||||||
|
- File using `st.navigation` → entry point for multi-page apps
|
||||||
|
- Single `.py` file at root with streamlit import → entry point
|
||||||
|
- Files in `pages/` or `app_pages/` subdirectory → **NOT entry points** (these are sub-pages)
|
||||||
|
|
||||||
|
3. **If entry point is obvious** → use it, no confirmation needed
|
||||||
|
|
||||||
|
Example: Found `streamlit_app.py` and `pages/metrics.py` → use `streamlit_app.py`
|
||||||
|
|
||||||
|
4. **Only ask if genuinely ambiguous** (e.g., multiple root-level candidates, none named `streamlit_app.py`):
|
||||||
|
```
|
||||||
|
Found multiple potential entry points:
|
||||||
|
- dashboard.py
|
||||||
|
- main.py
|
||||||
|
|
||||||
|
Which is your main app?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** Path to the main Streamlit source file(s)
|
||||||
|
|
||||||
|
### Step 2: Identify Task Type and Route to Sub-Skill
|
||||||
|
|
||||||
|
**Goal:** Determine what the user needs and load the appropriate guidance.
|
||||||
|
|
||||||
|
Use this routing table to select sub-skill(s). **Always read the sub-skill file** before making changes:
|
||||||
|
|
||||||
|
| User Need | Sub-skill to Read |
|
||||||
|
|-----------|-------------------|
|
||||||
|
| **Performance issues, slow apps, caching** | `read skills/optimizing-streamlit-performance/SKILL.md` |
|
||||||
|
| **Building a dashboard with KPIs/metrics** | `read skills/building-streamlit-dashboards/SKILL.md` |
|
||||||
|
| **Improving visual design, icons, polish** | `read skills/improving-streamlit-design/SKILL.md` |
|
||||||
|
| **Choosing widgets (selectbox vs radio vs pills)** | `read skills/choosing-streamlit-selection-widgets/SKILL.md` |
|
||||||
|
| **Styling widgets (button colors, backgrounds, CSS)** | `read skills/creating-streamlit-themes/SKILL.md` |
|
||||||
|
| **Layouts (columns, tabs, sidebar, containers)** | `read skills/using-streamlit-layouts/SKILL.md` |
|
||||||
|
| **Displaying data (dataframes, charts)** | `read skills/displaying-streamlit-data/SKILL.md` |
|
||||||
|
| **Multi-page app architecture** | `read skills/building-streamlit-multipage-apps/SKILL.md` |
|
||||||
|
| **Session state and callbacks** | `read skills/using-streamlit-session-state/SKILL.md` |
|
||||||
|
| **Markdown, colored text, badges** | `read skills/using-streamlit-markdown/SKILL.md` |
|
||||||
|
| **Custom themes and colors** | `read skills/creating-streamlit-themes/SKILL.md` |
|
||||||
|
| **Comprehensive theme design and brand alignment** | `read skills/creating-streamlit-themes/SKILL.md` |
|
||||||
|
| **Chat interfaces and AI assistants** | `read skills/building-streamlit-chat-ui/SKILL.md` |
|
||||||
|
| **Connecting to Snowflake** | `read skills/connecting-streamlit-to-snowflake/SKILL.md` |
|
||||||
|
| **Building or packaging a custom component, triggering events back to Python from JS/HTML, custom HTML/JS with event handling (CCv2), OR any UI element that doesn't exist as a native Streamlit widget** (e.g., drag-and-drop, custom interactive visualization, canvas drawing) | `read skills/building-streamlit-custom-components-v2/SKILL.md` — **IMPORTANT: `st.components.v1` is deprecated. Never use v1 for new components; always use `st.components.v2.component()`.** |
|
||||||
|
| **Third-party components** | `read skills/using-streamlit-custom-components/SKILL.md` |
|
||||||
|
| **Code organization** | `read skills/organizing-streamlit-code/SKILL.md` |
|
||||||
|
| **Environment setup** | `read skills/setting-up-streamlit-environment/SKILL.md` |
|
||||||
|
| **CLI commands** | `read skills/using-streamlit-cli/SKILL.md` |
|
||||||
|
|
||||||
|
**Fallback — "this widget doesn't exist in Streamlit":**
|
||||||
|
|
||||||
|
If the user asks for a UI element or interaction that **has never been part of Streamlit's API** and cannot be built with any combination of native widgets (e.g., drag-and-drop, canvas drawing, custom interactive visualizations), **route to the CCv2 sub-skill** (`skills/building-streamlit-custom-components-v2/SKILL.md`). **Do not** route to CCv2 for features that exist in newer Streamlit versions (e.g., `st.connection`, `st.segmented_control`) — suggest upgrading instead.
|
||||||
|
|
||||||
|
**Common combinations:**
|
||||||
|
|
||||||
|
For **beautifying/improving an app**, read in order:
|
||||||
|
1. `skills/improving-streamlit-design/SKILL.md`
|
||||||
|
2. `skills/using-streamlit-layouts/SKILL.md`
|
||||||
|
3. `skills/choosing-streamlit-selection-widgets/SKILL.md`
|
||||||
|
|
||||||
|
For **building a dashboard**, read:
|
||||||
|
1. `skills/building-streamlit-dashboards/SKILL.md`
|
||||||
|
2. `skills/displaying-streamlit-data/SKILL.md`
|
||||||
|
|
||||||
|
**IMPORTANT - Use templates:**
|
||||||
|
|
||||||
|
When creating a **new dashboard app**, prefer starting from a template in `templates/apps/`:
|
||||||
|
- If a template closely matches the request, copy it and adapt:
|
||||||
|
- `dashboard-metrics` / `dashboard-metrics-snowflake` — KPI cards with time-series charts
|
||||||
|
- `dashboard-companies` — company/entity comparison
|
||||||
|
- `dashboard-compute` / `dashboard-compute-snowflake` — resource/credit monitoring
|
||||||
|
- `dashboard-feature-usage` — feature adoption tracking
|
||||||
|
- `dashboard-seattle-weather` — public dataset exploration (local only)
|
||||||
|
- `dashboard-stock-peers` / `dashboard-stock-peers-snowflake` — financial peer analysis
|
||||||
|
- If no template is a close match, start from scratch but borrow relevant patterns from the templates (e.g., caching with `@st.cache_data`, `filter_by_time_range()`, `st.set_page_config()`, chart utilities, layout structure)
|
||||||
|
- See `templates/apps/README.md` for template descriptions
|
||||||
|
|
||||||
|
When **editing an existing app**, use templates as reference for best practices:
|
||||||
|
- Check `templates/apps/` for caching patterns, layout structure, and Snowflake integration
|
||||||
|
- Apply consistent patterns from templates to improve the existing code
|
||||||
|
|
||||||
|
When applying a **custom theme**, use a template from `templates/themes/`:
|
||||||
|
- Copy a theme directory (snowflake, dracula, nord, stripe, solarized-light, spotify, github, minimal)
|
||||||
|
- Themes use Google Fonts for easy setup
|
||||||
|
- See `templates/themes/README.md` for theme previews
|
||||||
|
|
||||||
|
For **performance optimization**, read:
|
||||||
|
1. `skills/optimizing-streamlit-performance/SKILL.md`
|
||||||
|
|
||||||
|
### Step 3: Apply Guidance to Edit Code
|
||||||
|
|
||||||
|
**Goal:** Make changes to the Streamlit app following sub-skill best practices.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
|
||||||
|
1. Apply the patterns and recommendations from the loaded sub-skill(s)
|
||||||
|
2. Make edits to the source file(s) identified in Step 1
|
||||||
|
3. Preserve existing functionality while adding improvements
|
||||||
|
|
||||||
|
### Step 4: Check Running Apps and Offer to Run
|
||||||
|
|
||||||
|
**Goal:** Help the user see their changes by checking if their app is running.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
|
||||||
|
1. **Check** for running Streamlit apps on ports 850*:
|
||||||
|
```bash
|
||||||
|
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -i python | awk '{print $2, $9}' | grep ':85' || echo "No Streamlit apps detected on ports 850*"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Present** findings to user:
|
||||||
|
|
||||||
|
**If app is running:**
|
||||||
|
```
|
||||||
|
Found Streamlit app running:
|
||||||
|
- PID: [pid] at http://localhost:[port]
|
||||||
|
|
||||||
|
Your changes should be visible after a page refresh (Streamlit hot-reloads on file save).
|
||||||
|
```
|
||||||
|
|
||||||
|
**If no app is running:**
|
||||||
|
```
|
||||||
|
No Streamlit app detected on ports 850*.
|
||||||
|
|
||||||
|
Would you like me to run the app? I can start it with:
|
||||||
|
streamlit run [app_file.py]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **If user wants to run the app**, start it:
|
||||||
|
```bash
|
||||||
|
streamlit run [path/to/app.py] --server.port 8501
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stopping Points
|
||||||
|
|
||||||
|
- **Step 2**: If multiple sub-skills seem relevant, ask user which aspect to focus on first
|
||||||
|
- **Step 4**: Ask before starting the Streamlit app
|
||||||
|
|
||||||
|
## Skill map
|
||||||
|
|
||||||
|
| Skill | Covers |
|
||||||
|
|-------|--------|
|
||||||
|
| [building-streamlit-chat-ui](skills/building-streamlit-chat-ui/SKILL.md) | Chat interfaces, streaming responses, message history |
|
||||||
|
| [building-streamlit-dashboards](skills/building-streamlit-dashboards/SKILL.md) | KPI cards, metrics, dashboard layouts |
|
||||||
|
| [building-streamlit-multipage-apps](skills/building-streamlit-multipage-apps/SKILL.md) | Page structure, navigation, shared state |
|
||||||
|
| [building-streamlit-custom-components-v2](skills/building-streamlit-custom-components-v2/SKILL.md) | Streamlit Custom Components v2 (inline and template-based packaged), bidirectional state/trigger callbacks, bundling, theme CSS variables |
|
||||||
|
| [choosing-streamlit-selection-widgets](skills/choosing-streamlit-selection-widgets/SKILL.md) | Selectbox vs radio vs segmented control vs pills vs multiselect |
|
||||||
|
| [connecting-streamlit-to-snowflake](skills/connecting-streamlit-to-snowflake/SKILL.md) | st.connection, query caching, credentials |
|
||||||
|
| [creating-streamlit-themes](skills/creating-streamlit-themes/SKILL.md) | Theme configuration, colors, fonts, light/dark modes, professional brand alignment, CSS avoidance |
|
||||||
|
| [displaying-streamlit-data](skills/displaying-streamlit-data/SKILL.md) | Dataframes, column config, charts |
|
||||||
|
| [improving-streamlit-design](skills/improving-streamlit-design/SKILL.md) | Icons, badges, colored text, visual polish |
|
||||||
|
| [optimizing-streamlit-performance](skills/optimizing-streamlit-performance/SKILL.md) | Caching, fragments, forms, static vs dynamic widgets |
|
||||||
|
| [organizing-streamlit-code](skills/organizing-streamlit-code/SKILL.md) | When to split into modules, separating UI from logic |
|
||||||
|
| [setting-up-streamlit-environment](skills/setting-up-streamlit-environment/SKILL.md) | Python environment, dependency management |
|
||||||
|
| [using-streamlit-custom-components](skills/using-streamlit-custom-components/SKILL.md) | Third-party components from the community |
|
||||||
|
| [using-streamlit-cli](skills/using-streamlit-cli/SKILL.md) | CLI commands, running apps |
|
||||||
|
| [using-streamlit-layouts](skills/using-streamlit-layouts/SKILL.md) | Sidebar, columns, containers, tabs, expanders, dialogs, alignment, spacing |
|
||||||
|
| [using-streamlit-markdown](skills/using-streamlit-markdown/SKILL.md) | Colored text, badges, icons, LaTeX, and all markdown features |
|
||||||
|
| [using-streamlit-session-state](skills/using-streamlit-session-state/SKILL.md) | Session state, widget keys, callbacks, state persistence |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Streamlit API Reference](https://docs.streamlit.io/develop/api-reference)
|
||||||
|
- [Streamlit Gallery](https://streamlit.io/gallery)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
name: building-streamlit-chat-ui
|
||||||
|
description: Building chat interfaces in Streamlit. Use when creating conversational UIs, chatbots, or AI assistants. Covers st.chat_message, st.chat_input, message history, and streaming responses.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit chat interfaces
|
||||||
|
|
||||||
|
Build conversational UIs with Streamlit's chat elements.
|
||||||
|
|
||||||
|
## Basic chat structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
if "messages" not in st.session_state:
|
||||||
|
st.session_state.messages = []
|
||||||
|
|
||||||
|
# Display chat history
|
||||||
|
for msg in st.session_state.messages:
|
||||||
|
with st.chat_message(msg["role"]):
|
||||||
|
st.write(msg["content"])
|
||||||
|
|
||||||
|
# Handle new input
|
||||||
|
if prompt := st.chat_input("Ask a question"):
|
||||||
|
st.session_state.messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write(prompt)
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
response = get_response(prompt) # Your LLM call
|
||||||
|
st.write(response)
|
||||||
|
|
||||||
|
st.session_state.messages.append({"role": "assistant", "content": response})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streaming responses
|
||||||
|
|
||||||
|
Use `st.write_stream` for token-by-token display. Pass any generator that yields strings, including the OpenAI generator directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_streaming_response(prompt):
|
||||||
|
# Replace with your LLM client (OpenAI, Anthropic, Cortex, etc.)
|
||||||
|
for chunk in your_llm_client.stream(prompt):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
response = st.write_stream(get_streaming_response(prompt))
|
||||||
|
|
||||||
|
st.session_state.messages.append({"role": "assistant", "content": response})
|
||||||
|
```
|
||||||
|
|
||||||
|
With OpenAI, you can pass the stream directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI()
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
stream = client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=st.session_state.messages,
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
response = st.write_stream(stream)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Chat message avatars
|
||||||
|
|
||||||
|
Streamlit provides default avatars for "user" and "assistant" roles—only customize if you have a specific need. You can use icons or images:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# With icons
|
||||||
|
with st.chat_message("assistant", avatar=":material/robot:"):
|
||||||
|
st.write(assistant_message)
|
||||||
|
|
||||||
|
# With images
|
||||||
|
with st.chat_message("user", avatar="https://example.com/avatar.png"):
|
||||||
|
st.write(user_message)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Suggestion chips
|
||||||
|
|
||||||
|
Offer clickable suggestions before the first message. The pills disappear once the user sends a message, creating a clean onboarding experience:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SUGGESTIONS = {
|
||||||
|
":blue[:material/help:] What is Streamlit?": "Explain what Streamlit is",
|
||||||
|
":green[:material/code:] Show me an example": "Show a simple Streamlit example",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only show before first message - they disappear after
|
||||||
|
if not st.session_state.messages:
|
||||||
|
selected = st.pills("Try asking:", list(SUGGESTIONS.keys()), label_visibility="collapsed")
|
||||||
|
if selected:
|
||||||
|
# Use the selection as the first prompt
|
||||||
|
prompt = SUGGESTIONS[selected]
|
||||||
|
st.session_state.messages.append({"role": "user", "content": prompt})
|
||||||
|
st.rerun()
|
||||||
|
```
|
||||||
|
|
||||||
|
The `if not st.session_state.messages` check ensures the suggestions only appear on an empty chat. Once a message is added, the pills vanish and the conversation takes over.
|
||||||
|
|
||||||
|
## File uploads
|
||||||
|
|
||||||
|
Enable file attachments with `accept_file`. When enabled, `st.chat_input` returns a dict-like object with `text` and `files` attributes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
prompt = st.chat_input(
|
||||||
|
"Ask about an image",
|
||||||
|
accept_file=True,
|
||||||
|
file_type=["jpg", "jpeg", "png"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if prompt:
|
||||||
|
with st.chat_message("user"):
|
||||||
|
if prompt.text:
|
||||||
|
st.write(prompt.text)
|
||||||
|
if prompt.files:
|
||||||
|
st.image(prompt.files[0])
|
||||||
|
|
||||||
|
# Send to vision model
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
response = analyze_image(prompt.files[0], prompt.text)
|
||||||
|
st.write(response)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `accept_file="multiple"` to allow multiple files.
|
||||||
|
|
||||||
|
## Audio input
|
||||||
|
|
||||||
|
Enable voice recording with `accept_audio`. The recorded audio is available as a WAV file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
prompt = st.chat_input("Say something", accept_audio=True)
|
||||||
|
|
||||||
|
if prompt:
|
||||||
|
if prompt.audio:
|
||||||
|
st.audio(prompt.audio)
|
||||||
|
if prompt.text:
|
||||||
|
st.write(prompt.text)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dictation with speech-to-text
|
||||||
|
|
||||||
|
Convert audio to text and inject it back into the chat input:
|
||||||
|
|
||||||
|
```python
|
||||||
|
prompt = st.chat_input("Say something", accept_audio=True, key="chat")
|
||||||
|
|
||||||
|
if prompt and prompt.audio:
|
||||||
|
# Transcribe with Whisper or another STT model
|
||||||
|
transcript = openai.audio.transcriptions.create(
|
||||||
|
model="whisper-1",
|
||||||
|
file=prompt.audio,
|
||||||
|
)
|
||||||
|
# Set the transcribed text as the next input
|
||||||
|
st.session_state.chat = transcript.text
|
||||||
|
st.rerun()
|
||||||
|
```
|
||||||
|
|
||||||
|
## User feedback
|
||||||
|
|
||||||
|
Add thumbs up/down feedback to assistant messages. Also supports `"stars"` and `"faces"` ratings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.markdown(response)
|
||||||
|
feedback = st.feedback("thumbs")
|
||||||
|
if feedback is not None:
|
||||||
|
st.toast(f"Feedback received: {'👍' if feedback == 1 else '👎'}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clear chat
|
||||||
|
|
||||||
|
Add a button to reset the conversation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def clear_chat():
|
||||||
|
st.session_state.messages = []
|
||||||
|
|
||||||
|
st.button("Clear chat", on_click=clear_chat)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related skills
|
||||||
|
|
||||||
|
- `connecting-streamlit-to-snowflake`: Database queries and Cortex chat example
|
||||||
|
- `optimizing-streamlit-performance`: Caching strategies for LLM calls
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.chat_message](https://docs.streamlit.io/develop/api-reference/chat/st.chat_message)
|
||||||
|
- [st.chat_input](https://docs.streamlit.io/develop/api-reference/chat/st.chat_input)
|
||||||
|
- [st.write_stream](https://docs.streamlit.io/develop/api-reference/write-magic/st.write_stream)
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
---
|
||||||
|
name: building-streamlit-custom-components-v2
|
||||||
|
description: Builds bidirectional Streamlit Custom Components v2 (CCv2) using `st.components.v2.component`. Use when authoring inline HTML/CSS/JS components or packaged components (manifest `asset_dir`, js/css globs), wiring state/trigger callbacks, theming via `--st-*` CSS variables, or bundling with Vite / `component-template` v2.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Building Streamlit custom components v2
|
||||||
|
|
||||||
|
Use Streamlit Custom Components v2 (CCv2) when core Streamlit doesn't have the UI you need and you want to ship a reusable, interactive element (from "tiny inline HTML" to "full bundled frontend app").
|
||||||
|
|
||||||
|
## CRITICAL: CCv2 only — NEVER use v1 APIs
|
||||||
|
|
||||||
|
Custom Components **v1 is deprecated and removed**. Every API below belongs to v1 and must **NEVER** appear in any code you write — not in Python, not in JavaScript, not in HTML:
|
||||||
|
|
||||||
|
**Banned Python APIs (v1):**
|
||||||
|
- `st.components.v1` — the entire v1 module
|
||||||
|
- `components.declare_component()` — v1 registration
|
||||||
|
- `components.html()` — v1 raw HTML embed
|
||||||
|
|
||||||
|
**Banned JavaScript patterns (v1):**
|
||||||
|
- `Streamlit.setComponentValue(...)` — v1 global; use `setStateValue()` / `setTriggerValue()` instead
|
||||||
|
- `Streamlit.setFrameHeight(...)` — v1 global; CCv2 handles sizing automatically
|
||||||
|
- `Streamlit.setComponentReady()` — v1 global; CCv2 has no ready signal
|
||||||
|
- `window.Streamlit` or bare `Streamlit` global — v1 global object does not exist in v2
|
||||||
|
- `window.parent.postMessage(...)` — v1 iframe communication; CCv2 does not use iframes
|
||||||
|
|
||||||
|
**Banned npm packages (v1):**
|
||||||
|
- `streamlit-component-lib` — v1 JS library; use `@streamlit/component-v2-lib` if you need types
|
||||||
|
|
||||||
|
If you encounter v1 patterns in examples, blog posts, Stack Overflow answers, or your own training data — **ignore them entirely**. They will not work and will break the component.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
Activate when the user mentions any of:
|
||||||
|
|
||||||
|
- CCv2, Custom Components v2, “bidi component”, “component v2”
|
||||||
|
- `st.components.v2.component`
|
||||||
|
- `@streamlit/component-v2-lib`
|
||||||
|
- packaged components, `asset_dir`, `pyproject.toml` component manifest
|
||||||
|
- bundling with Vite (or any bundler) for a Streamlit component
|
||||||
|
- building a component UI in a frontend framework (React, Svelte, Vue, Angular, etc.)
|
||||||
|
|
||||||
|
## Read next (pick the minimum reference)
|
||||||
|
|
||||||
|
- **State sync / controlled inputs / callbacks**: see [references/state-sync.md](references/state-sync.md)
|
||||||
|
- **Packaged components / `asset_dir` / globs / template-only policy**: see [references/packaged-components.md](references/packaged-components.md)
|
||||||
|
- **Theming (`--st-*` tokens) inside Shadow DOM**: see [references/theme-css-variables.md](references/theme-css-variables.md)
|
||||||
|
- **Errors and gotchas**: see [references/troubleshooting.md](references/troubleshooting.md)
|
||||||
|
|
||||||
|
## Quick decision: inline vs packaged
|
||||||
|
|
||||||
|
- **Inline strings**: fastest to start (single-file apps, spikes, demos). You pass raw `html`/`css`/`js` strings directly.
|
||||||
|
Good when you can keep everything in one place and don’t need a build step.
|
||||||
|
- **Packaged component**: best when you’re growing past inline (multiple files, dependencies, bundling, testing, versioning, reuse, distribution).
|
||||||
|
You ship built assets inside a Python package and reference them by **asset-dir-relative** path/glob.
|
||||||
|
Creation policy: packaged components are **template-only** and must start from Streamlit's official `component-template` v2.
|
||||||
|
|
||||||
|
Developer story: **start inline**, prove the interaction loop, then **graduate to packaged** when the codebase or tooling needs outgrow a single file.
|
||||||
|
|
||||||
|
## CCv2 model (what’s actually happening)
|
||||||
|
|
||||||
|
1. **Python registers** a component with `st.components.v2.component(...)` and gets back a **mount callable**.
|
||||||
|
2. The mount callable **mounts** the component in the app with `data=...`, layout (`width`, `height`), and optional `on_<key>_change` callbacks.
|
||||||
|
3. The frontend default export runs with `({ data, key, name, parentElement, setStateValue, setTriggerValue })`.
|
||||||
|
4. The component returns a **result object** whose attributes correspond to **state keys** and **trigger keys**.
|
||||||
|
|
||||||
|
## Best practice: wrap the mount callable in your own Python API
|
||||||
|
|
||||||
|
Prefer exposing **your own** Python function that wraps the callable returned by `st.components.v2.component(...)`.
|
||||||
|
|
||||||
|
This gives you a clean, stable API surface for end users (typed parameters, validation, friendly defaults) and keeps `data=...`, `default=...`, and callback wiring as an internal detail.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- Declare the component **once** (usually at module import time). Avoid defining and registering the component inside a function you call multiple times; you can accidentally re-register the component name and get confusing behavior.
|
||||||
|
|
||||||
|
References:
|
||||||
|
|
||||||
|
- [`st.components.v2.component`](https://docs.streamlit.io/develop/api-reference/custom-components/st.components.v2.component)
|
||||||
|
- [`ComponentRenderer` (mount callable type)](https://docs.streamlit.io/develop/api-reference/custom-components/st.components.v2.types.componentrenderer)
|
||||||
|
|
||||||
|
Example pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import streamlit as st
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
_MY_COMPONENT = st.components.v2.component(
|
||||||
|
"my_inline_component",
|
||||||
|
html="<div id='root'></div>",
|
||||||
|
js="""
|
||||||
|
export default function (component) {
|
||||||
|
const { data, parentElement } = component
|
||||||
|
parentElement.querySelector("#root").textContent = data?.label ?? ""
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def my_component(
|
||||||
|
label: str,
|
||||||
|
*,
|
||||||
|
key: str | None = None,
|
||||||
|
on_value_change: Callable[[], None] | None = None,
|
||||||
|
on_submitted_change: Callable[[], None] | None = None,
|
||||||
|
):
|
||||||
|
# Callbacks are optional, but if you want result attributes to always exist,
|
||||||
|
# provide (even empty) callbacks.
|
||||||
|
if on_value_change is None:
|
||||||
|
on_value_change = lambda: None
|
||||||
|
if on_submitted_change is None:
|
||||||
|
on_submitted_change = lambda: None
|
||||||
|
|
||||||
|
return _MY_COMPONENT(
|
||||||
|
data={"label": label},
|
||||||
|
key=key,
|
||||||
|
on_value_change=on_value_change,
|
||||||
|
on_submitted_change=on_submitted_change,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inline quickstart (state + trigger)
|
||||||
|
|
||||||
|
**Reminder: use ONLY v2 APIs.** Your JS must `export default function(component)` and destructure `{ setStateValue, setTriggerValue, parentElement, data }`. NEVER use `Streamlit.setComponentValue()`, `window.Streamlit`, or any v1 pattern.
|
||||||
|
|
||||||
|
This is the minimum "bidi loop":
|
||||||
|
|
||||||
|
- **JS → Python**: emit updates via `setStateValue(...)` (persistent) and `setTriggerValue(...)` (event)
|
||||||
|
- **Python → JS**: re-hydrate UI via `data=...` on every run
|
||||||
|
|
||||||
|
```python
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
HTML = """<input id="txt" /><button id="btn" type="button">Submit</button>"""
|
||||||
|
|
||||||
|
JS = """\
|
||||||
|
export default function (component) {
|
||||||
|
const { data, parentElement, setStateValue, setTriggerValue } = component
|
||||||
|
|
||||||
|
const input = parentElement.querySelector("#txt")
|
||||||
|
const btn = parentElement.querySelector("#btn")
|
||||||
|
if (!input || !btn) return
|
||||||
|
|
||||||
|
const nextValue = (data && data.value) ?? ""
|
||||||
|
if (input.value !== nextValue) input.value = nextValue
|
||||||
|
|
||||||
|
input.oninput = (e) => {
|
||||||
|
setStateValue("value", e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.onclick = () => {
|
||||||
|
setTriggerValue("submitted", input.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
my_text_input = st.components.v2.component(
|
||||||
|
"my_inline_text_input",
|
||||||
|
html=HTML,
|
||||||
|
js=JS,
|
||||||
|
)
|
||||||
|
|
||||||
|
KEY = "txt-1"
|
||||||
|
component_state = st.session_state.get(KEY, {})
|
||||||
|
value = component_state.get("value", "")
|
||||||
|
|
||||||
|
result = my_text_input(
|
||||||
|
key=KEY,
|
||||||
|
data={"value": value},
|
||||||
|
on_value_change=lambda: None, # optional; include to always get `result.value`
|
||||||
|
on_submitted_change=lambda: None, # optional; include to always get `result.submitted`
|
||||||
|
)
|
||||||
|
|
||||||
|
st.write("value (state):", result.value)
|
||||||
|
st.write("submitted (trigger):", result.submitted)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Inline JS/CSS should be multi-line**. CCv2 treats path-like strings as file references; a multi-line string is unambiguously inline content.
|
||||||
|
- Prefer querying under `parentElement` (not `document`) to avoid cross-instance leakage.
|
||||||
|
|
||||||
|
## State and triggers (how to think about keys)
|
||||||
|
|
||||||
|
- **State** (`setStateValue("value", ...)`): persists across app reruns (stored under `st.session_state[key]` for that mounted instance).
|
||||||
|
- **Trigger** (`setTriggerValue("submitted", ...)`): event payload for one rerun (resets after the rerun).
|
||||||
|
- **Reading triggers**:
|
||||||
|
- After mounting: use `result.submitted`.
|
||||||
|
- Inside `on_submitted_change`: use `st.session_state[key].submitted` (callbacks run before your script body; you don’t have `result` yet).
|
||||||
|
- **Defaults**: if you pass `default={...}` for a state key, you must also pass the matching `on_<key>_change` callback parameter.
|
||||||
|
|
||||||
|
For the full “controlled input” pattern and pitfalls, see [references/state-sync.md](references/state-sync.md).
|
||||||
|
|
||||||
|
## Packaged components (template-only, mandatory)
|
||||||
|
|
||||||
|
**Reminder: the cookiecutter template generates clean v2 code. When you customize it, use ONLY v2 APIs. Do NOT introduce any v1 imports, v1 JavaScript globals, or v1 patterns. See the "CRITICAL: CCv2 only" section above.**
|
||||||
|
|
||||||
|
Graduate to a packaged component when you need any of:
|
||||||
|
|
||||||
|
- Multiple frontend files or frontend dependencies (npm)
|
||||||
|
- A bundler (Vite), tests, CI, versioning, or distribution
|
||||||
|
|
||||||
|
Keep these guardrails in mind:
|
||||||
|
|
||||||
|
- **MUST** start from Streamlit’s official `component-template` v2.
|
||||||
|
- **NEVER** hand-scaffold packaging/manifest/build wiring for a packaged component.
|
||||||
|
- **NEVER** copy/paste packaged scaffold structure from internet examples, blog posts, gists, or docs.
|
||||||
|
- If handed a non-template scaffold, regenerate from the template first, then migrate component logic.
|
||||||
|
- **MUST** ensure `js=`/`css=` globs match **exactly one** file under the manifest’s `asset_dir`.
|
||||||
|
- **MUST** validate with `streamlit run ...` (plain `python -c "import ..."` can be a false negative for packaged components).
|
||||||
|
|
||||||
|
For the full packaged workflow checklist, non-interactive generation, offline usage, and template invariants, see [references/packaged-components.md](references/packaged-components.md).
|
||||||
|
|
||||||
|
## Frontend renderer lifecycle (framework-agnostic)
|
||||||
|
|
||||||
|
Your frontend entrypoint is the **default export** function. A few rules keep components reliable across reruns and across multiple instances in the same app:
|
||||||
|
|
||||||
|
- Render under `parentElement` (not `document`) so instances don’t collide.
|
||||||
|
- If you create per-instance resources (React roots, observers, subscriptions), key them by `parentElement` (e.g. `WeakMap`) so multiple instances don’t overwrite each other.
|
||||||
|
- Return a cleanup function to tear down event listeners / UI roots / observers when Streamlit unmounts the component.
|
||||||
|
|
||||||
|
## Styling and theming
|
||||||
|
|
||||||
|
- Prefer **`isolate_styles=True`** (default). Your component runs in a shadow root and won’t leak styles into the app.
|
||||||
|
- Set `isolate_styles=False` only when you need global styling behavior (e.g. Tailwind, global font injection).
|
||||||
|
- Streamlit injects a broad set of `--st-*` theme CSS variables (colors, typography, chart palettes, radii, borders, etc.). **Highly recommended:** use these variables so your component automatically adapts to the user’s current Streamlit theme (light/dark/custom) without authoring separate theme variants. Start with the common ones (`--st-text-color`, `--st-primary-color`, `--st-secondary-background-color`) and refer to the full list when you need it:
|
||||||
|
- [references/theme-css-variables.md](references/theme-css-variables.md)
|
||||||
|
|
||||||
|
## Troubleshooting and gotchas
|
||||||
|
|
||||||
|
Start here when something “should work” but doesn’t:
|
||||||
|
|
||||||
|
- [references/troubleshooting.md](references/troubleshooting.md)
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
## Packaged CCv2 components (template-only, mandatory)
|
||||||
|
|
||||||
|
For packaged CCv2 components, agents **MUST** use Streamlit's official template as the starting point for every new component project.
|
||||||
|
|
||||||
|
- [Streamlit component-template](https://github.com/streamlit/component-template)
|
||||||
|
|
||||||
|
Never hand-scaffold the package/manifest/build layout and never copy/paste a packaged component scaffold from blog posts, gists, docs, or other internet sources.
|
||||||
|
|
||||||
|
If a request starts from a non-template scaffold, stop and regenerate from the template first, then port logic into the generated project.
|
||||||
|
|
||||||
|
Follow your generated project's README. **Only keep reading if you need to debug template wiring or customize behavior after template generation.**
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Agent policy: template-only (mandatory)
|
||||||
|
- Prerequisites (packaged components)
|
||||||
|
- Start inline, then graduate to packaged
|
||||||
|
- Frontend framework note (React is optional)
|
||||||
|
- TypeScript support (recommended)
|
||||||
|
- Generate a new CCv2 component project
|
||||||
|
- Non-interactive generation (cookiecutter keys)
|
||||||
|
- Offline/airgapped
|
||||||
|
- Dev loop (template default)
|
||||||
|
- Verify the build output (prevents most load failures)
|
||||||
|
- Template invariants (don’t break these)
|
||||||
|
- Rename checklist (avoid placeholder-name drift)
|
||||||
|
- If you intentionally deviate from the template
|
||||||
|
- Verification recommendation
|
||||||
|
|
||||||
|
### Agent policy: template-only (mandatory)
|
||||||
|
|
||||||
|
If the request is for a packaged CCv2 component:
|
||||||
|
|
||||||
|
- Start from the official template first (no exceptions).
|
||||||
|
- Never manually scaffold a custom package/manifest/build layout before template generation.
|
||||||
|
- Never copy a packaged component scaffold from the internet, even as a "starting point."
|
||||||
|
- If given existing non-template scaffolding, regenerate from the template and migrate code into it.
|
||||||
|
- Customize only after generation so you retain known-good packaging defaults.
|
||||||
|
|
||||||
|
### Prerequisites (packaged components)
|
||||||
|
|
||||||
|
- **Python build tooling**: `uv` (recommended) + `cookiecutter`.
|
||||||
|
- **Frontend build tooling**: Node.js + npm.
|
||||||
|
|
||||||
|
### Start inline, then graduate to packaged
|
||||||
|
|
||||||
|
Inline components are great for getting started quickly. Move to a packaged component when you hit any of these:
|
||||||
|
|
||||||
|
- You need **multiple frontend files** (components/modules) instead of one big string.
|
||||||
|
- You want to pull in **frontend libraries** (npm deps) and run a bundler.
|
||||||
|
- You need **tests**, CI, versioning, or distribution (PyPI/private index).
|
||||||
|
|
||||||
|
### Frontend framework note (React is optional)
|
||||||
|
|
||||||
|
The official Streamlit `component-template` v2 supports both **React + TypeScript (Vite)** and **Pure TypeScript (Vite)** (no React). CCv2 also works with **any frontend framework that compiles to JavaScript** (Svelte, Vue, Angular, vanilla TS/JS, etc.).
|
||||||
|
|
||||||
|
The only requirement is that you produce JS/CSS assets into your component’s `asset_dir`, then register them from Python via `html=...`, `js="..."`, and `css="..."` using **asset-dir-relative** paths/globs.
|
||||||
|
|
||||||
|
### TypeScript support (recommended)
|
||||||
|
|
||||||
|
For end-to-end type safety while authoring the frontend, install `@streamlit/component-v2-lib`:
|
||||||
|
|
||||||
|
- [npm package](https://www.npmjs.com/package/@streamlit/component-v2-lib)
|
||||||
|
- [docs](https://docs.streamlit.io/develop/api-reference/custom-components/component-v2-lib)
|
||||||
|
|
||||||
|
It provides TypeScript types like `FrontendRenderer` / `FrontendRendererArgs` so your `export default` renderer gets a **typed** `data` payload and typed state/trigger keys via generics.
|
||||||
|
|
||||||
|
### Generate a new CCv2 component project
|
||||||
|
|
||||||
|
This command is the required starting point for every packaged CCv2 component:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2
|
||||||
|
```
|
||||||
|
|
||||||
|
If you run this non-interactively, pass explicit cookiecutter values (do not rely on defaults):
|
||||||
|
|
||||||
|
Template keys:
|
||||||
|
|
||||||
|
- `author_name`
|
||||||
|
- `author_email`
|
||||||
|
- `project_name`
|
||||||
|
- `package_name`
|
||||||
|
- `import_name`
|
||||||
|
- `description`
|
||||||
|
- `open_source_license`
|
||||||
|
- `framework`
|
||||||
|
|
||||||
|
Recommended non-interactive invocation:
|
||||||
|
|
||||||
|
This sample uses a **hypothetical breadcrumb component** name so the values are concrete and meaningful:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from cookiecutter cookiecutter gh:streamlit/component-template \
|
||||||
|
--directory cookiecutter/v2 \
|
||||||
|
--no-input \
|
||||||
|
author_name="Your Name" \
|
||||||
|
author_email="you@example.com" \
|
||||||
|
project_name="Streamlit Breadcrumbs" \
|
||||||
|
package_name="streamlit-breadcrumbs" \
|
||||||
|
import_name="streamlit_breadcrumbs" \
|
||||||
|
description="Packaged Streamlit CCv2 breadcrumb component" \
|
||||||
|
open_source_license="Apache-2.0" \
|
||||||
|
framework="React + Typescript"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Choice values must match template options exactly (`framework` is `"React + Typescript"` or `"Pure Typescript"`).
|
||||||
|
- Passing all keys avoids template placeholder names and post-generation rename churn.
|
||||||
|
|
||||||
|
Offline/airgapped:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from cookiecutter cookiecutter /path/to/component-template --directory cookiecutter/v2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev loop (template default)
|
||||||
|
|
||||||
|
From the generated project:
|
||||||
|
|
||||||
|
1. Activate the target project environment before Python/uv commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /path/to/project/.venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build the frontend assets (from `<import_name>/frontend`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Editable install (project root containing `pyproject.toml`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv pip install -e . --force-reinstall
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the example app with Streamlit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this order:
|
||||||
|
|
||||||
|
- Building first ensures `asset_dir` contains the expected files before install/use.
|
||||||
|
- Reinstalling editable after key renames keeps metadata and import paths in sync.
|
||||||
|
|
||||||
|
### Packaged component workflow (copy/paste checklist)
|
||||||
|
|
||||||
|
Use this when debugging or customizing after generation; it's designed to prevent the common "built assets exist but Streamlit can't load them" failure modes.
|
||||||
|
|
||||||
|
```
|
||||||
|
Packaged CCv2 checklist
|
||||||
|
- [ ] Generate project from `component-template` v2
|
||||||
|
- [ ] Confirm this is template-generated (not hand-scaffolded, not copied from internet snippets)
|
||||||
|
- [ ] Activate the target project environment before Python/uv commands
|
||||||
|
- [ ] Rename template defaults (`streamlit-component-x`, `streamlit_component_x`, etc.) if needed
|
||||||
|
- [ ] Build frontend assets into the manifest’s `asset_dir` (template: `frontend/build/`)
|
||||||
|
- [ ] Editable install the Python package (`uv pip install -e . --force-reinstall`)
|
||||||
|
- [ ] Verify `js=`/`css=` globs match exactly one file each under `asset_dir`
|
||||||
|
- [ ] Run via `streamlit run ...` and confirm the component renders/events work
|
||||||
|
- [ ] If something breaks: read `references/troubleshooting.md`, fix, rebuild, re-verify glob uniqueness
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify the build output (prevents most load failures)
|
||||||
|
|
||||||
|
- Ensure the manifest’s `asset_dir` exists and contains the built assets.
|
||||||
|
- Ensure each glob you register from Python matches **exactly one** file under `asset_dir`:
|
||||||
|
- Typical: `js="index-*.js"` and `css="index-*.css"`
|
||||||
|
- If multiple matches: clean the build output (template: `npm run clean`) and rebuild.
|
||||||
|
|
||||||
|
### Template invariants (don’t break these)
|
||||||
|
|
||||||
|
You typically shouldn’t need to touch these, but they explain most “why won’t this load?” failures:
|
||||||
|
|
||||||
|
- **Component key**: the Python registration key must match the manifest: `"<project.name>.<component.name>"`.
|
||||||
|
- **Manifest must ship inside the Python package**: the template places a minimal CCv2 manifest at `<import_name>/pyproject.toml` with `asset_dir = "frontend/build"`.
|
||||||
|
- **Asset paths are asset-dir-relative strings**: `js="index-*.js"` (template default output) or `js="assets/index-*.js"` (if you configured an `assets/` subdir).
|
||||||
|
- **Globs must match exactly one file**: if `index-*.js` matches multiple hashed builds, clean the build output (`npm run clean`) and rebuild.
|
||||||
|
|
||||||
|
### Rename checklist (avoid placeholder-name drift)
|
||||||
|
|
||||||
|
Template defaults like `streamlit-component-x` / `streamlit_component_x` should be replaced everywhere early.
|
||||||
|
|
||||||
|
Rename all of these together:
|
||||||
|
|
||||||
|
- Root folder name (optional but recommended for clarity).
|
||||||
|
- Distribution name (`[project].name`) in root `pyproject.toml`.
|
||||||
|
- Import package directory (`streamlit_<real_name>`).
|
||||||
|
- In-package manifest file and contents (`<import_name>/pyproject.toml`).
|
||||||
|
- Wrapper registration key:
|
||||||
|
- `st.components.v2.component("<project.name>.<component.name>", ...)`
|
||||||
|
- `MANIFEST.in` and `[tool.setuptools.*]` references.
|
||||||
|
- README/example imports and frontend package name.
|
||||||
|
|
||||||
|
### Allowed customizations (after template generation only)
|
||||||
|
|
||||||
|
Keep the blast radius small:
|
||||||
|
|
||||||
|
- If you change output layout, update only the `js=`/`css=` asset-dir-relative globs in the Python wrapper.
|
||||||
|
- For Vite, keep `base: "./"` so relative URLs work when served from Streamlit’s component URLs.
|
||||||
|
|
||||||
|
### Verification recommendation
|
||||||
|
|
||||||
|
Validate packaged components with `streamlit run ...`, not plain `python -c "import ..."` checks.
|
||||||
|
|
||||||
|
- Streamlit discovers component manifests as part of runtime setup.
|
||||||
|
- Plain import checks can report false-negative `asset_dir` registration errors for otherwise-correct packaged components.
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
## State sync patterns (JS ↔ Python)
|
||||||
|
|
||||||
|
This reference shows the canonical CCv2 “controlled component” loop and the most common pitfalls when syncing state between JavaScript and Python.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Mental model
|
||||||
|
- Canonical pattern: controlled text input
|
||||||
|
- JavaScript (hydrate from `data`, emit via `setStateValue`)
|
||||||
|
- Python wrapper (feed state back down via `data`)
|
||||||
|
- Defaults: when to use `default=...` (and why it fails)
|
||||||
|
- Python → JS hydration: initial-only vs true sync
|
||||||
|
- Session State timing: don’t mutate after mount
|
||||||
|
- Troubleshooting checklist
|
||||||
|
|
||||||
|
### Mental model
|
||||||
|
|
||||||
|
- **Frontend state emission (JS → Python)**: you explicitly call `setStateValue(key, value)` or `setTriggerValue(key, value)`.
|
||||||
|
- **Frontend state hydration (Python → JS)**: your JS reads `component.data` and updates the DOM accordingly.
|
||||||
|
- There is no built-in “two-way binding”: you must implement both sides.
|
||||||
|
|
||||||
|
### Canonical pattern: controlled text input
|
||||||
|
|
||||||
|
This is modeled after Streamlit’s own CCv2 e2e example.
|
||||||
|
|
||||||
|
#### JavaScript (hydrate from `data`, emit via `setStateValue`)
|
||||||
|
|
||||||
|
Key guideline: only assign to the input when it’s different, or you’ll fight the user’s cursor.
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default function (component) {
|
||||||
|
const { parentElement, data, setStateValue } = component
|
||||||
|
|
||||||
|
const label = parentElement.querySelector("label")
|
||||||
|
const input = parentElement.querySelector("input")
|
||||||
|
if (!label || !input) return
|
||||||
|
|
||||||
|
label.innerText = data.label
|
||||||
|
|
||||||
|
const nextValue = data.value ?? ""
|
||||||
|
if (input.value !== nextValue) {
|
||||||
|
input.value = nextValue
|
||||||
|
}
|
||||||
|
|
||||||
|
input.onkeydown = e => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
setStateValue("value", e.target.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python wrapper (feed state back down via `data`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
_COMPONENT = st.components.v2.component(
|
||||||
|
"interactive_text_input",
|
||||||
|
html="""
|
||||||
|
<label for="txt">Enter text:</label>
|
||||||
|
<input id="txt" type="text" />
|
||||||
|
""",
|
||||||
|
js=JS, # inline JS string from above
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_text_input(*, label: str, initial_value: str, key: str):
|
||||||
|
# 1) Read current component state from Session State (if it exists)
|
||||||
|
component_state = st.session_state.get(key, {})
|
||||||
|
|
||||||
|
# 2) Compute the value you want the UI to display
|
||||||
|
value = component_state.get("value", initial_value)
|
||||||
|
|
||||||
|
# 3) Send it down to the frontend via `data`
|
||||||
|
return _COMPONENT(
|
||||||
|
key=key,
|
||||||
|
data={"label": label, "value": value},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
KEY = "my_text_input"
|
||||||
|
|
||||||
|
if st.button("Make it say Hello World"):
|
||||||
|
st.session_state.setdefault(KEY, {})["value"] = "Hello World"
|
||||||
|
|
||||||
|
interactive_text_input(label="Enter something", initial_value="Initial Text", key=KEY)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Defaults: when to use `default=...` (and why it fails)
|
||||||
|
|
||||||
|
`default={...}` is optional. Use it when you want Streamlit to initialize missing state keys for a mounted instance.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Defaults apply only to **state** keys (not triggers).
|
||||||
|
- Every key in `default` must have a corresponding `on_<key>_change` callback parameter when mounting, or Streamlit raises.
|
||||||
|
|
||||||
|
Pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = _COMPONENT(
|
||||||
|
key=key,
|
||||||
|
data={"value": value},
|
||||||
|
default={"value": value},
|
||||||
|
on_value_change=lambda: None, # required if using default["value"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python → JS hydration: initial-only vs true sync
|
||||||
|
|
||||||
|
You’ll see two patterns in the wild:
|
||||||
|
|
||||||
|
- **Initial-only hydration**: JS reads `data.initialX` on first mount only. This is useful for *initialization* but it will **not** reflect later Python changes.
|
||||||
|
- **True sync (controlled)**: JS reconciles its UI from `data.value` on every render, and only writes when changed.
|
||||||
|
|
||||||
|
Initial-only example (pitfall for sync):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// If you guard hydration with hasMounted, Python changes won't propagate.
|
||||||
|
if (typeof data?.initialText !== "undefined" && !hasMountedForKey) {
|
||||||
|
input.value = String(data.initialText)
|
||||||
|
}
|
||||||
|
hasMounted[key] = true
|
||||||
|
```
|
||||||
|
|
||||||
|
True sync approach (recommended when Python can update the UI):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const nextValue = data.value ?? ""
|
||||||
|
if (input.value !== nextValue) input.value = nextValue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session State timing: don’t mutate after mount
|
||||||
|
|
||||||
|
Streamlit may raise if you modify `st.session_state.<key>.<field>` **after** the component with that key has been instantiated in the same run.
|
||||||
|
|
||||||
|
Safe patterns:
|
||||||
|
|
||||||
|
- Update `st.session_state[key][...]` **before** mounting the component (e.g., in a button handler placed above the mount call).
|
||||||
|
- Or update state in a different run (trigger a rerun after setting state).
|
||||||
|
|
||||||
|
### Troubleshooting checklist
|
||||||
|
|
||||||
|
- **Cursor jumps / typing feels broken**: ensure your JS only assigns `input.value` when it differs from the `data` value.
|
||||||
|
- **Python updates don’t reflect in UI**: confirm you pass the updated values via `data` every run; avoid initial-only hydration guards if you want true sync.
|
||||||
|
- **`default` raises**: ensure every default key has a corresponding `on_<key>_change` callback parameter.
|
||||||
|
- **Session state mutation error**: move `st.session_state[key][...] = ...` earlier in the script (before mount), or restructure into a two-run flow (set state then rerun).
|
||||||
|
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
## Streamlit theme CSS variables for CCv2 components (`--st-*`)
|
||||||
|
|
||||||
|
Streamlit injects a set of `--st-*` CSS custom properties into CCv2 components so your component can match the app’s active theme from within your component CSS (including when `isolate_styles=True` and you’re rendering inside a shadow root).
|
||||||
|
|
||||||
|
**Recommendation:** Prefer `var(--st-…)` over hard-coded colors and sizes. These variables automatically adapt to a user's current Streamlit theme (light/dark/custom), so most components **do not** need separate “dark mode vs light mode” styling.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Quick start
|
||||||
|
- Gotchas: serialization + fallbacks
|
||||||
|
- Cheat sheet: the 90% variables (intent → `--st-*`)
|
||||||
|
- Foundation tokens (surfaces, text, borders, shape)
|
||||||
|
- Typography tokens
|
||||||
|
- Data display tokens (dataframes/tables)
|
||||||
|
- Data visualization tokens (chart palettes)
|
||||||
|
- Semantic/status palette (red/green/etc.)
|
||||||
|
- Appendix: full variable index (alphabetical)
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
Use them like any CSS variable:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card {
|
||||||
|
background: var(--st-secondary-background-color);
|
||||||
|
color: var(--st-text-color);
|
||||||
|
border: 1px solid var(--st-border-color);
|
||||||
|
border-radius: var(--st-base-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton {
|
||||||
|
background: var(--st-primary-color);
|
||||||
|
color: var(--st-background-color);
|
||||||
|
border-radius: var(--st-button-radius);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gotchas: serialization rules + safe fallbacks
|
||||||
|
|
||||||
|
These variables originate from Streamlit’s theme object and are serialized into strings:
|
||||||
|
|
||||||
|
- **Strings**: passed through (e.g. `--st-primary-color: #ff4b4b`).
|
||||||
|
- **Numbers**: stringified (e.g. `--st-base-font-weight: 400`).
|
||||||
|
- **Booleans**: become `"1"` or `"0"` (e.g. `--st-link-underline`).
|
||||||
|
- **Arrays**: become a comma-joined string (e.g. `--st-heading-font-sizes: 2.75rem,2.25rem,...`).
|
||||||
|
- If you need individual items in JS, split on `","`.
|
||||||
|
- **Missing values (`null` / `undefined`)**: become `unset` so consumers fall back to initial/inherited CSS behavior.
|
||||||
|
|
||||||
|
### Cheat sheet: the 90% variables (intent → `--st-*`)
|
||||||
|
|
||||||
|
Use this section as your starting point. It covers what most components need most of the time.
|
||||||
|
|
||||||
|
| Intent | Variables |
|
||||||
|
| ------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| App/page background | `--st-background-color` |
|
||||||
|
| Panel/card background | `--st-secondary-background-color` |
|
||||||
|
| Body text | `--st-text-color` |
|
||||||
|
| Headings | `--st-heading-color`, `--st-heading-font` |
|
||||||
|
| Primary accent / emphasis | `--st-primary-color` |
|
||||||
|
| Links | `--st-link-color`, `--st-link-underline` |
|
||||||
|
| Borders / dividers | `--st-border-color`, `--st-border-color-light` |
|
||||||
|
| Widget outline borders | `--st-widget-border-color` |
|
||||||
|
| Corner radius | `--st-base-radius`, `--st-button-radius` |
|
||||||
|
| Code blocks | `--st-code-background-color`, `--st-code-text-color`, `--st-code-font` |
|
||||||
|
|
||||||
|
### Foundation tokens (surfaces, text, borders, shape)
|
||||||
|
|
||||||
|
#### Surfaces and content colors
|
||||||
|
|
||||||
|
- `--st-background-color` (page background)
|
||||||
|
- `--st-secondary-background-color` (panels, cards, containers)
|
||||||
|
- `--st-text-color` (default text)
|
||||||
|
- `--st-heading-color` (derived heading color)
|
||||||
|
- `--st-primary-color` (brand / accent)
|
||||||
|
- `--st-link-color` (link color)
|
||||||
|
- `--st-link-underline` (boolean serialized to `"1"` / `"0"`)
|
||||||
|
|
||||||
|
#### Borders and separators
|
||||||
|
|
||||||
|
- `--st-border-color` (default borders/dividers)
|
||||||
|
- `--st-border-color-light` (derived lighter border)
|
||||||
|
- `--st-widget-border-color` (widget borders)
|
||||||
|
|
||||||
|
#### Shape (radii)
|
||||||
|
|
||||||
|
- `--st-base-radius`
|
||||||
|
- `--st-button-radius`
|
||||||
|
|
||||||
|
#### Code block colors
|
||||||
|
|
||||||
|
- `--st-code-background-color`
|
||||||
|
- `--st-code-text-color`
|
||||||
|
|
||||||
|
### Typography tokens
|
||||||
|
|
||||||
|
#### Font families
|
||||||
|
|
||||||
|
- `--st-font` (body font)
|
||||||
|
- `--st-heading-font`
|
||||||
|
- `--st-code-font`
|
||||||
|
|
||||||
|
#### Body sizing and weights
|
||||||
|
|
||||||
|
- `--st-base-font-size`
|
||||||
|
- `--st-base-font-weight` (number)
|
||||||
|
|
||||||
|
#### Headings (H1–H6)
|
||||||
|
|
||||||
|
Arrays (comma-joined):
|
||||||
|
|
||||||
|
- `--st-heading-font-sizes` (array; typically 6 values for H1–H6)
|
||||||
|
- `--st-heading-font-weights` (array; typically 6 values for H1–H6)
|
||||||
|
|
||||||
|
Per-level convenience variables:
|
||||||
|
|
||||||
|
- `--st-heading-font-size-1`
|
||||||
|
- `--st-heading-font-size-2`
|
||||||
|
- `--st-heading-font-size-3`
|
||||||
|
- `--st-heading-font-size-4`
|
||||||
|
- `--st-heading-font-size-5`
|
||||||
|
- `--st-heading-font-size-6`
|
||||||
|
- `--st-heading-font-weight-1` (number)
|
||||||
|
- `--st-heading-font-weight-2` (number)
|
||||||
|
- `--st-heading-font-weight-3` (number)
|
||||||
|
- `--st-heading-font-weight-4` (number)
|
||||||
|
- `--st-heading-font-weight-5` (number)
|
||||||
|
- `--st-heading-font-weight-6` (number)
|
||||||
|
|
||||||
|
#### Code typography
|
||||||
|
|
||||||
|
- `--st-code-font-size`
|
||||||
|
- `--st-code-font-weight` (number)
|
||||||
|
|
||||||
|
### Data display tokens (dataframes/tables)
|
||||||
|
|
||||||
|
- `--st-dataframe-border-color`
|
||||||
|
- `--st-dataframe-header-background-color`
|
||||||
|
|
||||||
|
### Data visualization tokens (chart palettes)
|
||||||
|
|
||||||
|
Chart palette variables are **arrays serialized as comma-joined strings**:
|
||||||
|
|
||||||
|
- `--st-chart-categorical-colors` (discrete series)
|
||||||
|
- `--st-chart-sequential-colors` (low → high)
|
||||||
|
- `--st-chart-diverging-colors` (negative ↔ positive around a midpoint)
|
||||||
|
|
||||||
|
If you need the palette values in JS, split on commas:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default function (component) {
|
||||||
|
const { parentElement } = component;
|
||||||
|
|
||||||
|
const host = parentElement.host ?? parentElement;
|
||||||
|
const raw = getComputedStyle(host)
|
||||||
|
.getPropertyValue('--st-chart-categorical-colors')
|
||||||
|
.trim();
|
||||||
|
const palette = raw ? raw.split(',') : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic/status palette
|
||||||
|
|
||||||
|
These are best for status UI (badges, alerts, validation messages), not for primary layout surfaces.
|
||||||
|
|
||||||
|
Each “family” typically comes in three variants:
|
||||||
|
|
||||||
|
- **Base**: `--st-<name>-color`
|
||||||
|
- **Background**: `--st-<name>-background-color`
|
||||||
|
- **Text**: `--st-<name>-text-color`
|
||||||
|
|
||||||
|
Families:
|
||||||
|
|
||||||
|
- Red: `--st-red-color`, `--st-red-background-color`, `--st-red-text-color`
|
||||||
|
- Orange: `--st-orange-color`, `--st-orange-background-color`, `--st-orange-text-color`
|
||||||
|
- Yellow: `--st-yellow-color`, `--st-yellow-background-color`, `--st-yellow-text-color`
|
||||||
|
- Blue: `--st-blue-color`, `--st-blue-background-color`, `--st-blue-text-color`
|
||||||
|
- Green: `--st-green-color`, `--st-green-background-color`, `--st-green-text-color`
|
||||||
|
- Violet: `--st-violet-color`, `--st-violet-background-color`, `--st-violet-text-color`
|
||||||
|
- Gray: `--st-gray-color`, `--st-gray-background-color`, `--st-gray-text-color`
|
||||||
|
|
||||||
|
### Appendix: full variable index (alphabetical)
|
||||||
|
|
||||||
|
- `--st-background-color`
|
||||||
|
- `--st-base-font-size`
|
||||||
|
- `--st-base-font-weight`
|
||||||
|
- `--st-base-radius`
|
||||||
|
- `--st-blue-background-color`
|
||||||
|
- `--st-blue-color`
|
||||||
|
- `--st-blue-text-color`
|
||||||
|
- `--st-border-color`
|
||||||
|
- `--st-border-color-light`
|
||||||
|
- `--st-button-radius`
|
||||||
|
- `--st-chart-categorical-colors`
|
||||||
|
- `--st-chart-diverging-colors`
|
||||||
|
- `--st-chart-sequential-colors`
|
||||||
|
- `--st-code-background-color`
|
||||||
|
- `--st-code-font`
|
||||||
|
- `--st-code-font-size`
|
||||||
|
- `--st-code-font-weight`
|
||||||
|
- `--st-code-text-color`
|
||||||
|
- `--st-dataframe-border-color`
|
||||||
|
- `--st-dataframe-header-background-color`
|
||||||
|
- `--st-font`
|
||||||
|
- `--st-gray-background-color`
|
||||||
|
- `--st-gray-color`
|
||||||
|
- `--st-gray-text-color`
|
||||||
|
- `--st-green-background-color`
|
||||||
|
- `--st-green-color`
|
||||||
|
- `--st-green-text-color`
|
||||||
|
- `--st-heading-color`
|
||||||
|
- `--st-heading-font`
|
||||||
|
- `--st-heading-font-size-1`
|
||||||
|
- `--st-heading-font-size-2`
|
||||||
|
- `--st-heading-font-size-3`
|
||||||
|
- `--st-heading-font-size-4`
|
||||||
|
- `--st-heading-font-size-5`
|
||||||
|
- `--st-heading-font-size-6`
|
||||||
|
- `--st-heading-font-sizes`
|
||||||
|
- `--st-heading-font-weight-1`
|
||||||
|
- `--st-heading-font-weight-2`
|
||||||
|
- `--st-heading-font-weight-3`
|
||||||
|
- `--st-heading-font-weight-4`
|
||||||
|
- `--st-heading-font-weight-5`
|
||||||
|
- `--st-heading-font-weight-6`
|
||||||
|
- `--st-heading-font-weights`
|
||||||
|
- `--st-link-color`
|
||||||
|
- `--st-link-underline`
|
||||||
|
- `--st-orange-background-color`
|
||||||
|
- `--st-orange-color`
|
||||||
|
- `--st-orange-text-color`
|
||||||
|
- `--st-primary-color`
|
||||||
|
- `--st-red-background-color`
|
||||||
|
- `--st-red-color`
|
||||||
|
- `--st-red-text-color`
|
||||||
|
- `--st-secondary-background-color`
|
||||||
|
- `--st-text-color`
|
||||||
|
- `--st-violet-background-color`
|
||||||
|
- `--st-violet-color`
|
||||||
|
- `--st-violet-text-color`
|
||||||
|
- `--st-widget-border-color`
|
||||||
|
- `--st-yellow-background-color`
|
||||||
|
- `--st-yellow-color`
|
||||||
|
- `--st-yellow-text-color`
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
## Troubleshooting CCv2 components
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Packaged assets and manifests (`asset_dir`, component key)
|
||||||
|
- Renaming / placeholder drift
|
||||||
|
- Inline strings vs file-backed assets (path heuristic)
|
||||||
|
- Globs (0 matches or multiple matches)
|
||||||
|
- Defaults, callbacks, and missing result attributes
|
||||||
|
- Keys (Python `key=` vs frontend `key`)
|
||||||
|
- Shadow DOM / `isolate_styles` surprises
|
||||||
|
- Frontend build (Vite) gotchas
|
||||||
|
- DOM clobbering (overwriting injected HTML/CSS)
|
||||||
|
|
||||||
|
### Packaged assets and manifests (`asset_dir`, component key)
|
||||||
|
|
||||||
|
#### “Component '<name>' must be declared in pyproject.toml with asset_dir to use file-backed js/css.”
|
||||||
|
|
||||||
|
You passed a **path-like** `js=`/`css=` string (like `index-*.js` or `assets/index-*.js`) but Streamlit can’t find an `asset_dir` for this component key.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- If you want **inline JS/CSS**, pass a **multi-line** string with the actual code (not a path).
|
||||||
|
- If you want **packaged assets**, ensure:
|
||||||
|
- Your wheel includes a `pyproject.toml` with `[[tool.streamlit.component.components]] ... asset_dir = ...`
|
||||||
|
- You call `st.components.v2.component("<project>.<component>", js="...", css="...")` with the matching fully-qualified key.
|
||||||
|
|
||||||
|
Important context:
|
||||||
|
|
||||||
|
- This error is expected if you test packaged wrappers via plain Python import in some environments.
|
||||||
|
- Prefer `streamlit run ...` for packaged verification because manifest discovery is part of Streamlit runtime initialization.
|
||||||
|
|
||||||
|
### Inline strings vs file-backed assets (path heuristic)
|
||||||
|
|
||||||
|
CCv2 uses a heuristic: strings that “look like” paths are treated as file references. A multi-line string is always treated as inline content.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Prefer triple-quoted multi-line strings for inline `html`/`css`/`js`.
|
||||||
|
- Avoid single-line minified JS/CSS in `js=`/`css=`; add a newline if you must.
|
||||||
|
|
||||||
|
### Globs (0 matches or multiple matches)
|
||||||
|
|
||||||
|
Globs must match **exactly one** file under `asset_dir`.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Clean the build output directory before rebuilding.
|
||||||
|
- Make your bundler output a predictable `index-<hash>.js` / `index-<hash>.css` (or `assets/index-<hash>...` if you emit into an `assets/` subdir).
|
||||||
|
- If you started from Streamlit’s `component-template`, run `npm run clean` from your `frontend/` directory to clear the `build/` output so `index-*.js` matches exactly one file.
|
||||||
|
|
||||||
|
### Renamed project/package still shows old template names
|
||||||
|
|
||||||
|
Symptoms:
|
||||||
|
|
||||||
|
- Old names like `streamlit-component-x` / `streamlit_component_x` remain in paths or metadata.
|
||||||
|
- Imports, manifest component keys, and registration keys no longer align.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Rename/update all related surfaces together:
|
||||||
|
- root `pyproject.toml` project name
|
||||||
|
- import package folder and `MANIFEST.in` paths
|
||||||
|
- `[tool.setuptools.packages.find]` and `[tool.setuptools.package-data]`
|
||||||
|
- in-package manifest (`<import_name>/pyproject.toml`)
|
||||||
|
- wrapper registration key (`"<project.name>.<component.name>"`)
|
||||||
|
- README/example imports and install commands
|
||||||
|
- Rebuild frontend and reinstall editable package after rename.
|
||||||
|
|
||||||
|
### Defaults, callbacks, and missing result attributes
|
||||||
|
|
||||||
|
#### `default={...}` doesn’t apply / missing result attributes
|
||||||
|
|
||||||
|
Defaults only apply to **state keys**, and Streamlit expects those keys to be declared via `on_<key>_change` callback parameters at mount time.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- If you pass `default={"value": ...}`, also pass `on_value_change=lambda: None`.
|
||||||
|
- For triggers, don’t expect defaults; triggers are transient and default to `None`.
|
||||||
|
|
||||||
|
### Keys (Python `key=` vs frontend `key`)
|
||||||
|
|
||||||
|
- Python `key=` is the user-visible Streamlit element key.
|
||||||
|
- The frontend also receives a `key` string that is **generated by Streamlit** and is not the same as the Python `key` unless you explicitly pass the user key through `data`.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- If your frontend needs a stable identifier, pass it in `data={"user_key": key, ...}`.
|
||||||
|
|
||||||
|
### Shadow DOM / `isolate_styles` surprises
|
||||||
|
|
||||||
|
#### Shadow DOM (`isolate_styles=True`) surprises
|
||||||
|
|
||||||
|
With `isolate_styles=True` (default):
|
||||||
|
|
||||||
|
- Your component is mounted in a **shadow root**.
|
||||||
|
- `parentElement` is a `ShadowRoot`.
|
||||||
|
- Global CSS (like Tailwind injected into the document) won’t automatically style your component.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Keep `isolate_styles=True` for safety and use CSS variables and component-local styles.
|
||||||
|
- Use `isolate_styles=False` only when you intentionally need global styling behavior.
|
||||||
|
|
||||||
|
### Frontend build gotchas (Vite)
|
||||||
|
|
||||||
|
If you deviate from the template’s Vite config (or you’re wiring Vite into an existing repo), these are the common footguns:
|
||||||
|
|
||||||
|
- **Missing `base: "./"`**: relative asset URLs can break when served from Streamlit’s component URL path.
|
||||||
|
- **Stale build artifacts**: Vite outputs hashed filenames; if you keep old builds around, `index-*.js` can match multiple files. Clean the build dir before rebuilding.
|
||||||
|
|
||||||
|
### DOM clobbering (overwriting injected HTML/CSS)
|
||||||
|
|
||||||
|
#### Accidentally overwriting your component DOM
|
||||||
|
|
||||||
|
If you directly set `parentElement.innerHTML = ...`, you can overwrite the HTML/CSS that Streamlit injected from your `html=`/`css=` arguments.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Prefer `querySelector` + modifying children.
|
||||||
|
- If you need dynamic HTML, create a new child element and set **that** element’s `innerHTML`.
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
name: building-streamlit-dashboards
|
||||||
|
description: Building dashboards in Streamlit. Use when creating KPI displays, metric cards, or data-heavy layouts. Covers borders, cards, responsive layouts, and dashboard composition.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit dashboards
|
||||||
|
|
||||||
|
Compose metrics, charts, and data into clean dashboard layouts.
|
||||||
|
|
||||||
|
## Cards with borders
|
||||||
|
|
||||||
|
Use `border=True` to create visual cards. Supported on `st.container`, `st.metric`, `st.columns`, and `st.form`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Container card
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("Sales Overview")
|
||||||
|
st.line_chart(sales_data)
|
||||||
|
|
||||||
|
# Metric card
|
||||||
|
st.metric("Revenue", "$1.2M", "+12%", border=True)
|
||||||
|
|
||||||
|
# Column cards
|
||||||
|
for col in st.columns(3, border=True):
|
||||||
|
with col:
|
||||||
|
st.metric("Users", "1.2k")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Card labels
|
||||||
|
|
||||||
|
Add context to cards with headers or bold text:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# With subheader
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("Monthly Trends")
|
||||||
|
st.line_chart(data)
|
||||||
|
|
||||||
|
# With bold label
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown("**Top Products**")
|
||||||
|
st.dataframe(top_products)
|
||||||
|
```
|
||||||
|
|
||||||
|
## KPI rows
|
||||||
|
|
||||||
|
Use horizontal containers for responsive metric rows:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.container(horizontal=True):
|
||||||
|
st.metric("Revenue", "$1.2M", "-7%", border=True)
|
||||||
|
st.metric("Users", "762k", "+12%", border=True)
|
||||||
|
st.metric("Orders", "1.4k", "+5%", border=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Horizontal containers wrap on smaller screens. Prefer them over `st.columns` for metric rows.
|
||||||
|
|
||||||
|
## Metrics with sparklines
|
||||||
|
|
||||||
|
Add trend context with `chart_data`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
weekly_values = [700, 720, 715, 740, 762, 755, 780]
|
||||||
|
|
||||||
|
st.metric(
|
||||||
|
"Active Users",
|
||||||
|
"780k",
|
||||||
|
"+3.2%",
|
||||||
|
border=True,
|
||||||
|
chart_data=weekly_values,
|
||||||
|
chart_type="line", # or "bar"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Sparklines show y-values only—use for evenly-spaced data like daily/weekly snapshots.
|
||||||
|
|
||||||
|
## Dashboard layout
|
||||||
|
|
||||||
|
Combine cards into a dashboard:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# KPI row
|
||||||
|
with st.container(horizontal=True):
|
||||||
|
st.metric("Revenue", "$1.2M", "-7%", border=True, chart_data=rev_trend, chart_type="line")
|
||||||
|
st.metric("Users", "762k", "+12%", border=True, chart_data=user_trend, chart_type="line")
|
||||||
|
st.metric("Orders", "1.4k", "+5%", border=True, chart_data=order_trend, chart_type="bar")
|
||||||
|
|
||||||
|
# Charts row
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("Revenue by Region")
|
||||||
|
st.bar_chart(region_data, x="region", y="revenue")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("Monthly Trend")
|
||||||
|
st.line_chart(monthly_data, x="month", y="value")
|
||||||
|
|
||||||
|
# Data table
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("Recent Orders")
|
||||||
|
st.dataframe(orders_df, hide_index=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sidebar filters
|
||||||
|
|
||||||
|
Put filters in the sidebar to maximize dashboard space:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.sidebar:
|
||||||
|
date_range = st.date_input("Date range", value=(start, end))
|
||||||
|
region = st.multiselect("Region", regions, default=regions)
|
||||||
|
|
||||||
|
# Main area is all dashboard content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard templates
|
||||||
|
|
||||||
|
Ready-to-use dashboard templates are available in `templates/apps/`:
|
||||||
|
|
||||||
|
| Template | Features |
|
||||||
|
|----------|----------|
|
||||||
|
| `dashboard-metrics` | Metric cards with sparklines, date filtering, focus mode |
|
||||||
|
| `dashboard-metrics-snowflake` | Same as above, with Snowflake connection |
|
||||||
|
| `dashboard-companies` | Company comparison, filterable data tables |
|
||||||
|
| `dashboard-compute` | `@st.fragment` for independent updates, popover filters |
|
||||||
|
| `dashboard-compute-snowflake` | Same as above, with Snowflake connection |
|
||||||
|
| `dashboard-feature-usage` | Feature adoption tracking, trend analysis |
|
||||||
|
| `dashboard-seattle-weather` | Weather data visualization |
|
||||||
|
| `dashboard-stock-peers` | Stock peer comparison |
|
||||||
|
| `dashboard-stock-peers-snowflake` | Same as above, with Snowflake connection |
|
||||||
|
|
||||||
|
Each template uses synthetic data that can be replaced with real queries. See `templates/apps/README.md` for setup instructions.
|
||||||
|
|
||||||
|
## Related skills
|
||||||
|
|
||||||
|
- `using-streamlit-layouts`: Columns, containers, tabs, dialogs
|
||||||
|
- `displaying-streamlit-data`: Charts, dataframes, column configuration
|
||||||
|
- `optimizing-streamlit-performance`: Caching and fragments for heavy dashboards
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.container](https://docs.streamlit.io/develop/api-reference/layout/st.container)
|
||||||
|
- [st.metric](https://docs.streamlit.io/develop/api-reference/data/st.metric)
|
||||||
|
- [st.columns](https://docs.streamlit.io/develop/api-reference/layout/st.columns)
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
---
|
||||||
|
name: building-streamlit-multipage-apps
|
||||||
|
description: Building multi-page Streamlit apps. Use when creating apps with multiple pages, setting up navigation, or managing state across pages.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit multi-page apps
|
||||||
|
|
||||||
|
Structure and navigation for apps with multiple pages.
|
||||||
|
|
||||||
|
## Directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
streamlit_app.py # Main entry point
|
||||||
|
app_pages/
|
||||||
|
home.py
|
||||||
|
analytics.py
|
||||||
|
settings.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Name your pages directory `app_pages/` (not `pages/`). Using `pages/` conflicts with Streamlit's old auto-discovery API and can cause unexpected behavior.
|
||||||
|
|
||||||
|
## Main module
|
||||||
|
|
||||||
|
```python
|
||||||
|
# streamlit_app.py
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
# Initialize global state (shared across pages)
|
||||||
|
if "api_client" not in st.session_state:
|
||||||
|
st.session_state.api_client = init_api_client()
|
||||||
|
|
||||||
|
# Define navigation
|
||||||
|
page = st.navigation([
|
||||||
|
st.Page("app_pages/home.py", title="Home", icon=":material/home:"),
|
||||||
|
st.Page("app_pages/analytics.py", title="Analytics", icon=":material/bar_chart:"),
|
||||||
|
st.Page("app_pages/settings.py", title="Settings", icon=":material/settings:"),
|
||||||
|
])
|
||||||
|
|
||||||
|
# App-level UI runs before page content
|
||||||
|
# Useful for shared elements like titles
|
||||||
|
st.title(f"{page.icon} {page.title}")
|
||||||
|
|
||||||
|
page.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** When you handle titles in `streamlit_app.py`, individual pages should NOT use `st.title` again.
|
||||||
|
|
||||||
|
## Navigation position
|
||||||
|
|
||||||
|
**Few pages (3-7) → Top navigation:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
page = st.navigation([...], position="top")
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a clean horizontal menu. Great for simple apps. Sections are supported too—they appear as dropdowns in the top nav.
|
||||||
|
|
||||||
|
**Many pages or nested sections → Sidebar:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
page = st.navigation({
|
||||||
|
"Main": [
|
||||||
|
st.Page("app_pages/home.py", title="Home"),
|
||||||
|
st.Page("app_pages/analytics.py", title="Analytics"),
|
||||||
|
],
|
||||||
|
"Admin": [
|
||||||
|
st.Page("app_pages/settings.py", title="Settings"),
|
||||||
|
st.Page("app_pages/users.py", title="Users"),
|
||||||
|
],
|
||||||
|
}, position="sidebar")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mixed: Some pages ungrouped:**
|
||||||
|
|
||||||
|
Use an empty string key `""` for pages that shouldn't be in a section. These ungrouped pages always appear first, before any named groups. Put all ungrouped pages in a single `""` key:
|
||||||
|
|
||||||
|
```python
|
||||||
|
page = st.navigation({
|
||||||
|
"": [
|
||||||
|
st.Page("app_pages/home.py", title="Home"),
|
||||||
|
st.Page("app_pages/about.py", title="About"),
|
||||||
|
],
|
||||||
|
"Analytics": [
|
||||||
|
st.Page("app_pages/dashboard.py", title="Dashboard"),
|
||||||
|
st.Page("app_pages/reports.py", title="Reports"),
|
||||||
|
],
|
||||||
|
}, position="top")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page modules
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app_pages/analytics.py
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
# Access global state
|
||||||
|
api = st.session_state.api_client
|
||||||
|
user = st.session_state.user
|
||||||
|
|
||||||
|
# Page-specific content (title is handled in streamlit_app.py)
|
||||||
|
data = api.fetch_analytics(user.id)
|
||||||
|
st.line_chart(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global state
|
||||||
|
|
||||||
|
Initialize state in the main module only if it's needed across multiple pages:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# streamlit_app.py
|
||||||
|
st.session_state.api = init_client()
|
||||||
|
st.session_state.user = get_user()
|
||||||
|
st.session_state.settings = load_settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tip:** Use `st.session_state.setdefault("key", default_value)` to initialize values only if they don't exist.
|
||||||
|
|
||||||
|
**Why main module (for global state):**
|
||||||
|
- Runs before every page
|
||||||
|
- Ensures state is initialized
|
||||||
|
- Single source of truth
|
||||||
|
|
||||||
|
## Page-specific state
|
||||||
|
|
||||||
|
Use prefixed keys for page-specific state:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app_pages/analytics.py
|
||||||
|
if "analytics_date_range" not in st.session_state:
|
||||||
|
st.session_state.analytics_date_range = default_range()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Share elements between pages
|
||||||
|
|
||||||
|
Put shared UI in the entrypoint (before `page.run()`) so it appears on all pages:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# streamlit_app.py
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
pages = [...]
|
||||||
|
page = st.navigation(pages)
|
||||||
|
|
||||||
|
# Shared title
|
||||||
|
st.title(f"{page.icon} {page.title}")
|
||||||
|
|
||||||
|
# Shared sidebar widgets
|
||||||
|
with st.sidebar:
|
||||||
|
st.selectbox("Theme", ["Light", "Dark"])
|
||||||
|
|
||||||
|
page.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Programmatic navigation
|
||||||
|
|
||||||
|
Navigate to another page programmatically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if st.button("Go to Settings"):
|
||||||
|
st.switch_page("app_pages/settings.py")
|
||||||
|
```
|
||||||
|
|
||||||
|
Create navigation links with `st.page_link`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.page_link("app_pages/home.py", label="Home", icon=":material/home:")
|
||||||
|
st.page_link("https://example.com", label="External", icon=":material/open_in_new:")
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Prefer `st.navigation` over `st.page_link` for standard navigation. Do not use `st.page_link` to recreate the nav bar you get with `st.navigation`. Only use `st.page_link` when linking to pages from somewhere other than the sidebar, or when building a more complex navigation menu.
|
||||||
|
|
||||||
|
## Conditional pages
|
||||||
|
|
||||||
|
Show different pages based on user role, authentication, or any other condition by building the pages list dynamically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# streamlit_app.py
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
pages = [st.Page("app_pages/home.py", title="Home", icon=":material/home:")]
|
||||||
|
|
||||||
|
if st.user.is_logged_in:
|
||||||
|
pages.append(st.Page("app_pages/dashboard.py", title="Dashboard", icon=":material/bar_chart:"))
|
||||||
|
|
||||||
|
if st.session_state.get("is_admin"):
|
||||||
|
pages.append(st.Page("app_pages/admin.py", title="Admin", icon=":material/settings:"))
|
||||||
|
|
||||||
|
page = st.navigation(pages)
|
||||||
|
page.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
Common conditions for showing/hiding pages:
|
||||||
|
- `st.user.is_logged_in` for authenticated users
|
||||||
|
- `st.session_state` flags (roles, permissions, feature flags)
|
||||||
|
- Environment variables or secrets
|
||||||
|
- Time-based access (e.g., beta features)
|
||||||
|
|
||||||
|
## Imports from pages
|
||||||
|
|
||||||
|
When importing from page files in `app_pages/`, always import from the root directory perspective:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app_pages/dashboard.py - GOOD
|
||||||
|
from utils.data import load_sales_data
|
||||||
|
|
||||||
|
# app_pages/dashboard.py - BAD (don't use relative imports)
|
||||||
|
from ..utils.data import load_sales_data
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Multipage apps docs](https://docs.streamlit.io/develop/concepts/multipage-apps)
|
||||||
|
- [st.navigation](https://docs.streamlit.io/develop/api-reference/navigation/st.navigation)
|
||||||
|
- [st.Page](https://docs.streamlit.io/develop/api-reference/navigation/st.page)
|
||||||
|
- [st.session_state](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.session_state)
|
||||||
|
- [st.switch_page](https://docs.streamlit.io/develop/api-reference/navigation/st.switch_page)
|
||||||
|
- [st.page_link](https://docs.streamlit.io/develop/api-reference/widgets/st.page_link)
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
name: choosing-streamlit-selection-widgets
|
||||||
|
description: Choosing the right Streamlit selection widget. Use when deciding between radio buttons, selectbox, segmented control, pills, or other option selection widgets. Helps pick the right widget for the number of options and selection type.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit selection widgets
|
||||||
|
|
||||||
|
The right selection widget for the job. Streamlit has evolved—many old patterns are now anti-patterns.
|
||||||
|
|
||||||
|
## When to use what
|
||||||
|
|
||||||
|
Use `st.segmented_control` or `st.pills` when you want all options visible at once. Use `st.selectbox` or `st.multiselect` when options should be hidden in a dropdown.
|
||||||
|
|
||||||
|
| Widget | Best For |
|
||||||
|
|--------|----------|
|
||||||
|
| `st.segmented_control` | 2-5 options, single select, all visible |
|
||||||
|
| `st.pills` | 2-5 options, multi-select, all visible |
|
||||||
|
| `st.selectbox` | Many options, single select, dropdown |
|
||||||
|
| `st.multiselect` | Many options, multi-select, dropdown |
|
||||||
|
|
||||||
|
## Segmented control (options visible, single select)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD
|
||||||
|
status = st.radio("Status", ["Draft", "Published"], horizontal=True)
|
||||||
|
|
||||||
|
# GOOD
|
||||||
|
status = st.segmented_control("Status", ["Draft", "Published"])
|
||||||
|
```
|
||||||
|
|
||||||
|
For vertical layouts, `st.radio(..., horizontal=False)` is still a great choice.
|
||||||
|
|
||||||
|
Cleaner, more modern look than horizontal radio buttons.
|
||||||
|
|
||||||
|
## Pills (options visible, multi-select)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Multi-select with few options
|
||||||
|
selected = st.pills(
|
||||||
|
"Tags",
|
||||||
|
["Python", "SQL", "dbt", "Streamlit"],
|
||||||
|
selection_mode="multi"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Can also be used to mimic an "example" widget, especially with `label_visibility="collapsed"`:
|
||||||
|
```python
|
||||||
|
st.pills("Examples", ["Show me sales data", "Top customers"], label_visibility="collapsed")
|
||||||
|
```
|
||||||
|
|
||||||
|
More visual and easier to use than `st.multiselect` for small option sets.
|
||||||
|
|
||||||
|
## Selectbox (many options, single select)
|
||||||
|
|
||||||
|
```python
|
||||||
|
country = st.selectbox(
|
||||||
|
"Select a country",
|
||||||
|
["USA", "UK", "Canada", "Germany", "France", ...]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dropdowns scale better than radio/pills for long lists.
|
||||||
|
|
||||||
|
## Multiselect (many options, multi-select)
|
||||||
|
|
||||||
|
```python
|
||||||
|
countries = st.multiselect(
|
||||||
|
"Select countries",
|
||||||
|
["USA", "UK", "Canada", "Germany", "France", ...]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Toggle vs checkbox
|
||||||
|
|
||||||
|
Use `st.toggle` for settings that trigger changes in the app. Reserve `st.checkbox` for forms.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GOOD: Toggle for app settings
|
||||||
|
dark_mode = st.toggle("Dark mode")
|
||||||
|
show_advanced = st.toggle("Show advanced options")
|
||||||
|
|
||||||
|
# GOOD: Checkbox in forms
|
||||||
|
with st.form("signup"):
|
||||||
|
agree = st.checkbox("I agree to the terms")
|
||||||
|
st.form_submit_button("Sign up")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forms with border=False
|
||||||
|
|
||||||
|
Remove the default form border for cleaner inline forms. Keep the border for longer forms where visual grouping helps.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inline form without border
|
||||||
|
with st.form(key="add_item", border=False):
|
||||||
|
with st.container(horizontal=True, vertical_alignment="bottom"):
|
||||||
|
st.text_input("New item", label_visibility="collapsed", placeholder="Add item")
|
||||||
|
st.form_submit_button("Add", icon=":material/add:")
|
||||||
|
|
||||||
|
# Longer form - keep the border for visual grouping
|
||||||
|
with st.form("signup"):
|
||||||
|
st.text_input("Name")
|
||||||
|
st.text_input("Email")
|
||||||
|
st.selectbox("Role", ["Admin", "User"])
|
||||||
|
st.form_submit_button("Submit")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom options in selectbox and multiselect
|
||||||
|
|
||||||
|
Allow users to add their own options with `accept_new_options`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Works with multiselect
|
||||||
|
tickers = st.multiselect(
|
||||||
|
"Stock tickers",
|
||||||
|
options=["AAPL", "MSFT", "GOOGL", "NVDA"],
|
||||||
|
default=["AAPL"],
|
||||||
|
accept_new_options=True,
|
||||||
|
placeholder="Choose stocks or type your own"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also works with selectbox
|
||||||
|
country = st.selectbox(
|
||||||
|
"Country",
|
||||||
|
options=["USA", "UK", "Canada"],
|
||||||
|
accept_new_options=True,
|
||||||
|
placeholder="Select or type a country"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.segmented_control](https://docs.streamlit.io/develop/api-reference/widgets/st.segmented_control)
|
||||||
|
- [st.pills](https://docs.streamlit.io/develop/api-reference/widgets/st.pills)
|
||||||
|
- [st.selectbox](https://docs.streamlit.io/develop/api-reference/widgets/st.selectbox)
|
||||||
|
- [st.multiselect](https://docs.streamlit.io/develop/api-reference/widgets/st.multiselect)
|
||||||
|
- [st.toggle](https://docs.streamlit.io/develop/api-reference/widgets/st.toggle)
|
||||||
|
- [st.checkbox](https://docs.streamlit.io/develop/api-reference/widgets/st.checkbox)
|
||||||
|
- [st.form](https://docs.streamlit.io/develop/api-reference/execution-flow/st.form)
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
---
|
||||||
|
name: connecting-streamlit-to-snowflake
|
||||||
|
description: Connecting Streamlit apps to Snowflake. Use when setting up database connections, managing secrets, or querying Snowflake from a Streamlit app.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit Snowflake connection
|
||||||
|
|
||||||
|
Connect your Streamlit app to Snowflake the right way.
|
||||||
|
|
||||||
|
## Use st.connection
|
||||||
|
|
||||||
|
Always use `st.connection("snowflake")` instead of raw connectors.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
conn = st.connection("snowflake")
|
||||||
|
|
||||||
|
# Query data
|
||||||
|
df = conn.query("SELECT * FROM my_table LIMIT 100")
|
||||||
|
st.dataframe(df)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why st.connection:**
|
||||||
|
- Automatic connection pooling
|
||||||
|
- Built-in caching
|
||||||
|
- Handles reconnection
|
||||||
|
- Works with st.secrets
|
||||||
|
|
||||||
|
## Caller's rights connection (Streamlit 1.53+)
|
||||||
|
|
||||||
|
For apps running in Snowflake, use caller's rights to run queries with the viewer's permissions instead of the app owner's:
|
||||||
|
|
||||||
|
```python
|
||||||
|
conn = st.connection("snowflake", type="snowflake-callers-rights")
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful when:
|
||||||
|
- Different users should see different data based on their Snowflake roles
|
||||||
|
- You want row-level security to apply based on the viewer
|
||||||
|
- You don't want the app to have elevated permissions
|
||||||
|
|
||||||
|
## Cached queries
|
||||||
|
|
||||||
|
Use the built-in `ttl` parameter to cache query results:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
conn = st.connection("snowflake")
|
||||||
|
|
||||||
|
# Cache for 10 minutes
|
||||||
|
df = conn.query("SELECT * FROM metrics", ttl=timedelta(minutes=10))
|
||||||
|
|
||||||
|
# Cache for 1 hour
|
||||||
|
df = conn.query("SELECT * FROM reference_data", ttl=3600)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure with st.secrets
|
||||||
|
|
||||||
|
Store credentials in `.streamlit/secrets.toml` (never commit this file).
|
||||||
|
|
||||||
|
**CRITICAL**: Derive the `account` and `host` values from the user's Snowflake CLI connection config. Run `snow connection list` and use the exact values. A wrong `account` will redirect to the wrong login page.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# .streamlit/secrets.toml
|
||||||
|
[connections.snowflake]
|
||||||
|
account = "ORGNAME-ACCTNAME" # from `snow connection list`
|
||||||
|
host = "myaccount.snowflakecomputing.com" # from `snow connection list` (include if present)
|
||||||
|
user = "your_user"
|
||||||
|
authenticator = "externalbrowser"
|
||||||
|
warehouse = "your_warehouse"
|
||||||
|
database = "your_database"
|
||||||
|
schema = "your_schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `.gitignore`:
|
||||||
|
```
|
||||||
|
.streamlit/secrets.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameterized queries
|
||||||
|
|
||||||
|
Use parameters to prevent SQL injection:
|
||||||
|
|
||||||
|
```python
|
||||||
|
conn = st.connection("snowflake")
|
||||||
|
|
||||||
|
# Safe: parameterized
|
||||||
|
df = conn.query(
|
||||||
|
"SELECT * FROM users WHERE region = :region",
|
||||||
|
params={"region": selected_region}
|
||||||
|
)
|
||||||
|
|
||||||
|
# UNSAFE: string formatting - don't do this
|
||||||
|
# df = conn.query(f"SELECT * FROM users WHERE region = '{selected_region}'")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Write data
|
||||||
|
|
||||||
|
Use the session for write operations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
conn = st.connection("snowflake")
|
||||||
|
session = conn.session()
|
||||||
|
|
||||||
|
# Write a dataframe
|
||||||
|
session.write_pandas(df, "MY_TABLE", auto_create_table=True)
|
||||||
|
|
||||||
|
# Execute statements
|
||||||
|
session.sql("INSERT INTO logs VALUES (:ts, :msg)", params={...}).collect()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple connections
|
||||||
|
|
||||||
|
Define multiple connections in secrets:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# .streamlit/secrets.toml
|
||||||
|
[connections.snowflake]
|
||||||
|
account = "prod_account"
|
||||||
|
# ... prod credentials
|
||||||
|
|
||||||
|
[connections.snowflake_staging]
|
||||||
|
account = "staging_account"
|
||||||
|
# ... staging credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
prod_conn = st.connection("snowflake")
|
||||||
|
staging_conn = st.connection("snowflake_staging")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Chat with Cortex
|
||||||
|
|
||||||
|
Build a chat interface using Snowflake Cortex LLMs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import streamlit as st
|
||||||
|
from snowflake.cortex import complete
|
||||||
|
|
||||||
|
st.set_page_config(page_title="AI Assistant", page_icon=":sparkles:")
|
||||||
|
|
||||||
|
if "messages" not in st.session_state:
|
||||||
|
st.session_state.messages = []
|
||||||
|
|
||||||
|
for msg in st.session_state.messages:
|
||||||
|
with st.chat_message(msg["role"]):
|
||||||
|
st.write(msg["content"])
|
||||||
|
|
||||||
|
if prompt := st.chat_input("Ask anything"):
|
||||||
|
st.session_state.messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write(prompt)
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
response = st.write_stream(
|
||||||
|
complete(
|
||||||
|
"claude-3-5-sonnet",
|
||||||
|
prompt,
|
||||||
|
session=st.connection("snowflake").session(),
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
st.session_state.messages.append({"role": "assistant", "content": response})
|
||||||
|
```
|
||||||
|
|
||||||
|
See `building-streamlit-chat-ui` for more chat patterns (avatars, suggestions, history management).
|
||||||
|
|
||||||
|
## Python 3.12+ dependency caveat
|
||||||
|
|
||||||
|
`streamlit[snowflake]` gates `snowflake-connector-python` on `python_version < "3.12"`. On Python 3.12+, the connector is silently skipped and you get `No module named 'snowflake'` at runtime. Always add `snowflake-connector-python>=3.3.0` as an explicit dependency in `pyproject.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
dependencies = [
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.connection](https://docs.streamlit.io/develop/api-reference/connections/st.connection)
|
||||||
|
- [SnowflakeConnection](https://docs.streamlit.io/develop/api-reference/connections/st.connections.snowflakeconnection)
|
||||||
|
- [st.secrets](https://docs.streamlit.io/develop/api-reference/connections/st.secrets)
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
---
|
||||||
|
name: creating-streamlit-themes
|
||||||
|
description: Creating and customizing Streamlit themes. Use when changing app colors, fonts, or appearance, or aligning apps to brand guidelines. Covers config.toml configuration, design principles, and CSS avoidance.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Creating Streamlit themes
|
||||||
|
|
||||||
|
Build professional, brand-aligned themes using `.streamlit/config.toml`. This skill covers design principles and complete configuration for polished, cohesive themes.
|
||||||
|
|
||||||
|
## Theme file setup
|
||||||
|
|
||||||
|
Theme options go in Streamlit's `config.toml` under the `[theme]` section:
|
||||||
|
|
||||||
|
## Theme inheritance
|
||||||
|
|
||||||
|
Start from a built-in theme or external file:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
base = "light" # or "dark"
|
||||||
|
# base = "./my-base-theme.toml" # Local file
|
||||||
|
# base = "https://example.com/theme.toml" # Remote URL
|
||||||
|
```
|
||||||
|
|
||||||
|
When using `base`, you only need to override the values you want to change. Theme files referenced via `base` can only contain a single `[theme]` section—`[theme.light]` and `[theme.dark]` variants are not supported in external theme files.
|
||||||
|
|
||||||
|
## Color configuration
|
||||||
|
|
||||||
|
### Theme colors
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
primaryColor = "#0969da" # Buttons, links, active elements
|
||||||
|
backgroundColor = "#ffffff" # Main content background
|
||||||
|
secondaryBackgroundColor = "#f6f8fa" # Widget backgrounds, code blocks
|
||||||
|
textColor = "#1F2328" # Body text
|
||||||
|
|
||||||
|
# Optional refinements
|
||||||
|
linkColor = "#0969da" # Markdown links (defaults to primaryColor)
|
||||||
|
codeTextColor = "#1F2328" # Inline code text
|
||||||
|
codeBackgroundColor = "#f6f8fa" # Code block background
|
||||||
|
borderColor = "#d0d7de" # Widget borders
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design principle:** Choose a `primaryColor` dark enough to contrast with white text. Streamlit renders the text of primary buttons white against the primary color.
|
||||||
|
|
||||||
|
### Color palette
|
||||||
|
|
||||||
|
Define semantic colors for status indicators, markdown text coloring, and sparklines:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
redColor = "#cf222e"
|
||||||
|
orangeColor = "#bf8700"
|
||||||
|
yellowColor = "#dbab09"
|
||||||
|
greenColor = "#1a7f37"
|
||||||
|
blueColor = "#0969da"
|
||||||
|
violetColor = "#8250df"
|
||||||
|
grayColor = "#57606a"
|
||||||
|
```
|
||||||
|
|
||||||
|
Each color supports background and text variants (auto-derived if not set):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
greenColor = "#1a7f37"
|
||||||
|
greenBackgroundColor = "#dafbe1" # Light tint for badges
|
||||||
|
greenTextColor = "#116329" # Darkened for readability
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chart colors
|
||||||
|
|
||||||
|
Define colors for Plotly, Altair, and Vega-Lite charts:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
# Categorical data (bars, pie slices, series)
|
||||||
|
chartCategoricalColors = ["#0969da", "#1a7f37", "#bf3989", "#8250df", "#cf222e", "#bf8700", "#57606a"]
|
||||||
|
|
||||||
|
# Sequential/gradient data (heatmaps) - exactly 10 colors required
|
||||||
|
chartSequentialColors = ["#f0f6fc", "#c8e1ff", "#79c0ff", "#58a6ff", "#388bfd", "#1f6feb", "#1158c7", "#0d419d", "#0a3069", "#04244a"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dataframe styling
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
dataframeBorderColor = "#d0d7de"
|
||||||
|
dataframeHeaderBackgroundColor = "#f6f8fa"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure `textColor` is readable against `dataframeHeaderBackgroundColor`—headers use the main text color.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Font families
|
||||||
|
|
||||||
|
Use built-in fonts, load from Google Fonts, or define custom fonts from font files (see below):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
# Built-in options
|
||||||
|
font = "sans-serif" # or "serif" or "monospace"
|
||||||
|
|
||||||
|
# Google Fonts
|
||||||
|
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||||
|
|
||||||
|
# Font with spaces in name
|
||||||
|
font = "'IBM Plex Sans':https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Self-hosting custom fonts
|
||||||
|
|
||||||
|
Use `[[theme.fontFaces]]` tables to load fonts via Streamlit's static file serving. Font files must be placed in a `static/` directory and served through the app—they cannot be arbitrary local file paths.
|
||||||
|
|
||||||
|
**Before adding fonts to config.toml:** Verify the font files exist in the static directory.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[theme.fontFaces]]
|
||||||
|
family = "CustomFont"
|
||||||
|
url = "app/static/CustomFont-Regular.woff2"
|
||||||
|
weight = 400
|
||||||
|
|
||||||
|
[[theme.fontFaces]]
|
||||||
|
family = "CustomFont"
|
||||||
|
url = "app/static/CustomFont-Bold.woff2"
|
||||||
|
weight = 700
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
font = "CustomFont"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Attributes:** `family` (name), `url` (path to OTF/TTF/WOFF/WOFF2), `weight` (400, "200 800", or "bold"), `style` ("normal"/"italic"/"oblique"), `unicodeRange` (e.g., "U+0000-00FF").
|
||||||
|
|
||||||
|
Changes to `fontFaces` require a server restart.
|
||||||
|
|
||||||
|
### Heading and code fonts
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
headingFont = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@600;700&display=swap"
|
||||||
|
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Font sizing and weight
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
baseFontSize = 14 # Root size in pixels (default: 16)
|
||||||
|
baseFontWeight = 400 # Normal weight
|
||||||
|
codeFontSize = "0.875rem" # Relative to base, or use "13px"
|
||||||
|
codeFontWeight = 400
|
||||||
|
|
||||||
|
# Heading hierarchy (h1 through h6), or use a single value for all
|
||||||
|
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [600, 600, 600, 500, 500, 500]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Link styling
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
linkUnderline = false # Remove underlines for cleaner look
|
||||||
|
```
|
||||||
|
|
||||||
|
## Border and radius
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
baseRadius = "8px" # All components (none/small/medium/large/full/px/rem)
|
||||||
|
buttonRadius = "8px" # Buttons specifically (defaults to baseRadius)
|
||||||
|
showWidgetBorder = true # Show borders on unfocused widgets
|
||||||
|
showSidebarBorder = true # Show divider between sidebar and content
|
||||||
|
```
|
||||||
|
|
||||||
|
**Radius keywords:** `"none"` (0), `"small"` (4px), `"medium"` (8px), `"large"` (12px), `"full"` (pill shape).
|
||||||
|
|
||||||
|
## Sidebar customization
|
||||||
|
|
||||||
|
Style the sidebar independently:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#f6f8fa"
|
||||||
|
secondaryBackgroundColor = "#eaeef2"
|
||||||
|
codeBackgroundColor = "#eaeef2"
|
||||||
|
textColor = "#1F2328"
|
||||||
|
borderColor = "#d0d7de"
|
||||||
|
primaryColor = "#0969da" # Active elements in sidebar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Light and dark modes
|
||||||
|
|
||||||
|
Define separate themes for each mode:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme.light]
|
||||||
|
primaryColor = "#0969da"
|
||||||
|
backgroundColor = "#ffffff"
|
||||||
|
secondaryBackgroundColor = "#f6f8fa"
|
||||||
|
textColor = "#1F2328"
|
||||||
|
|
||||||
|
[theme.dark]
|
||||||
|
primaryColor = "#58a6ff"
|
||||||
|
backgroundColor = "#0d1117"
|
||||||
|
secondaryBackgroundColor = "#161b22"
|
||||||
|
textColor = "#e6edf3"
|
||||||
|
|
||||||
|
[theme.light.sidebar]
|
||||||
|
backgroundColor = "#f6f8fa"
|
||||||
|
|
||||||
|
[theme.dark.sidebar]
|
||||||
|
backgroundColor = "#010409"
|
||||||
|
```
|
||||||
|
|
||||||
|
Users can switch between modes in the app settings menu only if both `[theme.light]` and `[theme.dark]` are defined. A custom theme with just `[theme]` locks the app to a single mode.
|
||||||
|
|
||||||
|
## Detecting current theme
|
||||||
|
|
||||||
|
Use `st.context.theme.base` to adapt your app to the active theme. Useful for:
|
||||||
|
|
||||||
|
- Adjusting specific chart colors for better contrast
|
||||||
|
- Swapping logos or images (e.g., dark logo on light, light logo on dark)
|
||||||
|
- Styling third-party components that don't auto-adapt
|
||||||
|
- Applying conditional CSS or custom styling
|
||||||
|
|
||||||
|
```python
|
||||||
|
if st.context.theme.base == "dark":
|
||||||
|
# Do something for dark mode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
### Color contrast
|
||||||
|
|
||||||
|
Ensure WCAG AA compliance (4.5:1 ratio for text):
|
||||||
|
- Light themes: Dark text (#1F2328) on light backgrounds (#ffffff)
|
||||||
|
- Dark themes: Light text (#e6edf3) on dark backgrounds (#0d1117)
|
||||||
|
- Primary colors must contrast with white button text
|
||||||
|
|
||||||
|
### Color harmony
|
||||||
|
|
||||||
|
Build cohesive palettes using these approaches:
|
||||||
|
|
||||||
|
**Monochromatic:** Single hue with varying lightness (e.g., shadcn's zinc grays)
|
||||||
|
```toml
|
||||||
|
primaryColor = "#18181B"
|
||||||
|
textColor = "#09090B"
|
||||||
|
borderColor = "#E4E4E7"
|
||||||
|
grayColor = "#71717A"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Brand accent:** Neutral base with one brand color (e.g., Stripe's purple)
|
||||||
|
```toml
|
||||||
|
primaryColor = "#635bff" # Brand purple
|
||||||
|
backgroundColor = "#ffffff"
|
||||||
|
textColor = "#425466" # Neutral gray
|
||||||
|
```
|
||||||
|
|
||||||
|
**Complementary:** Brand primary with supporting accent colors
|
||||||
|
```toml
|
||||||
|
primaryColor = "#29B5E8" # Brand blue (Snowflake)
|
||||||
|
textColor = "#11567F" # Darker blue for text
|
||||||
|
greenColor = "#36B37E" # Success states
|
||||||
|
redColor = "#DE350B" # Error states
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography guidelines
|
||||||
|
|
||||||
|
- **Body text:** 14-16px, weight 400
|
||||||
|
- **Headings:** Decreasing scale from h1 (28-40px) to h6 (12-14px)
|
||||||
|
- **Code:** Monospace font, slightly smaller than body (0.85-0.875rem)
|
||||||
|
- **Font pairing:** Use the same font for body and headings for consistency, or pair complementary fonts (e.g., serif headings with sans-serif body). Code should always use a distinct monospace font.
|
||||||
|
|
||||||
|
### Visual hierarchy
|
||||||
|
|
||||||
|
Create depth with background layers:
|
||||||
|
```
|
||||||
|
Main content: #ffffff (lightest)
|
||||||
|
Secondary elements: #f6f8fa (slightly darker)
|
||||||
|
Sidebar: #f6f8fa or contrasting brand color
|
||||||
|
Code blocks: #f6f8fa (matches secondary or distinct)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Snowflake brand theme
|
||||||
|
|
||||||
|
Clean, professional theme with brand blue accents:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
primaryColor = "#29B5E8"
|
||||||
|
backgroundColor = "#ffffff"
|
||||||
|
secondaryBackgroundColor = "#f4f9fc"
|
||||||
|
codeBackgroundColor = "#e8f4f8"
|
||||||
|
textColor = "#11567F"
|
||||||
|
linkColor = "#29B5E8"
|
||||||
|
borderColor = "#d0e8f2"
|
||||||
|
showWidgetBorder = true
|
||||||
|
showSidebarBorder = true
|
||||||
|
baseRadius = "8px"
|
||||||
|
buttonRadius = "8px"
|
||||||
|
|
||||||
|
font = "'Inter':https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||||
|
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "13px"
|
||||||
|
codeTextColor = "#11567F"
|
||||||
|
baseFontSize = 14
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [600, 600, 600, 500, 500, 500]
|
||||||
|
linkUnderline = false
|
||||||
|
|
||||||
|
chartCategoricalColors = ["#29B5E8", "#11567F", "#71C8E5", "#174D6A", "#A5DDF2", "#0E4D6B", "#52B8D9"]
|
||||||
|
|
||||||
|
blueColor = "#29B5E8"
|
||||||
|
greenColor = "#36B37E"
|
||||||
|
yellowColor = "#FFAB00"
|
||||||
|
redColor = "#DE350B"
|
||||||
|
violetColor = "#6554C0"
|
||||||
|
|
||||||
|
dataframeBorderColor = "#d0e8f2"
|
||||||
|
dataframeHeaderBackgroundColor = "#e8f4f8"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#11567F"
|
||||||
|
secondaryBackgroundColor = "#174D6A"
|
||||||
|
codeBackgroundColor = "#0E4D6B"
|
||||||
|
textColor = "#ffffff"
|
||||||
|
borderColor = "#1E6D94"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: VS Code dark theme
|
||||||
|
|
||||||
|
Developer-focused dark theme with syntax-inspired colors:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
base = "dark"
|
||||||
|
primaryColor = "#0078d4"
|
||||||
|
backgroundColor = "#1e1e1e"
|
||||||
|
secondaryBackgroundColor = "#252526"
|
||||||
|
codeBackgroundColor = "#1e1e1e"
|
||||||
|
textColor = "#cccccc"
|
||||||
|
linkColor = "#3794ff"
|
||||||
|
borderColor = "#3c3c3c"
|
||||||
|
showWidgetBorder = true
|
||||||
|
showSidebarBorder = true
|
||||||
|
baseRadius = "4px"
|
||||||
|
buttonRadius = "4px"
|
||||||
|
|
||||||
|
font = "'Segoe UI', 'Open Sans':https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap"
|
||||||
|
codeFont = "'Fira Code':https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "13px"
|
||||||
|
codeTextColor = "#d4d4d4"
|
||||||
|
baseFontSize = 14
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["28px", "22px", "18px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [600, 600, 600, 600, 600, 600]
|
||||||
|
linkUnderline = false
|
||||||
|
|
||||||
|
chartCategoricalColors = ["#0078d4", "#4ec9b0", "#dcdcaa", "#ce9178", "#c586c0", "#569cd6", "#6a9955"]
|
||||||
|
|
||||||
|
blueColor = "#569cd6"
|
||||||
|
greenColor = "#6a9955"
|
||||||
|
yellowColor = "#dcdcaa"
|
||||||
|
orangeColor = "#ce9178"
|
||||||
|
violetColor = "#c586c0"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#252526"
|
||||||
|
secondaryBackgroundColor = "#333333"
|
||||||
|
codeBackgroundColor = "#1e1e1e"
|
||||||
|
borderColor = "#3c3c3c"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common mistakes
|
||||||
|
|
||||||
|
### Primary color too light
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# BAD: White text on yellow is unreadable
|
||||||
|
primaryColor = "#FFEB3B"
|
||||||
|
|
||||||
|
# GOOD: Use a darker shade
|
||||||
|
primaryColor = "#F59E0B"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Insufficient contrast
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# BAD: Light gray text on white
|
||||||
|
textColor = "#CCCCCC"
|
||||||
|
backgroundColor = "#FFFFFF"
|
||||||
|
|
||||||
|
# GOOD: Dark text on light background
|
||||||
|
textColor = "#1F2328"
|
||||||
|
backgroundColor = "#FFFFFF"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mismatched backgrounds
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# BAD: Secondary lighter than primary
|
||||||
|
backgroundColor = "#f6f8fa"
|
||||||
|
secondaryBackgroundColor = "#ffffff"
|
||||||
|
|
||||||
|
# GOOD: Secondary should be darker/distinct
|
||||||
|
backgroundColor = "#ffffff"
|
||||||
|
secondaryBackgroundColor = "#f6f8fa"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forgetting sidebar contrast
|
||||||
|
|
||||||
|
When using a dark sidebar with a light main section, adjust all sidebar colors—not just `textColor`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# BAD: Only changed backgroundColor
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#11567F"
|
||||||
|
|
||||||
|
# GOOD: Adjust all colors for dark sidebar
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#11567F"
|
||||||
|
secondaryBackgroundColor = "#174D6A"
|
||||||
|
textColor = "#ffffff"
|
||||||
|
borderColor = "#1E6D94"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## IMPORTANT: No custom CSS unless explicitly requested
|
||||||
|
|
||||||
|
**DO NOT use custom CSS or HTML for theming.** This includes:
|
||||||
|
- `st.markdown(..., unsafe_allow_html=True)` with `<style>` or inline styles
|
||||||
|
- `st.html()` with `<style>` blocks
|
||||||
|
- Any HTML/CSS for colors, backgrounds, fonts, or visual styling
|
||||||
|
|
||||||
|
**Only use CSS if the user explicitly asks for it** (e.g., "add custom CSS", "use st.html for styling"). For brand colors, theming, and visual identity—always use `config.toml`.
|
||||||
|
|
||||||
|
Native theming is cleaner, more maintainable, and won't break with Streamlit updates.
|
||||||
|
|
||||||
|
If the user explicitly asks for CSS, use `key=` to create targetable classes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.button("Submit", key="submit")
|
||||||
|
# Generates: .st-key-submit
|
||||||
|
|
||||||
|
st.html("""<style>.st-key-submit button { width: 100%; }</style>""")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never use CSS for theming (colors, backgrounds, fonts) unless explicitly asked. Use config.toml instead.**
|
||||||
|
|
||||||
|
## Development workflow
|
||||||
|
|
||||||
|
Most theme options update live after saving `config.toml` and rerunning. Font-related options (`fontFaces`) require a server restart.
|
||||||
|
|
||||||
|
Test your theme with: buttons (primary contrast), forms (borders, focus), dataframes (headers), code blocks, charts, and sidebar.
|
||||||
|
|
||||||
|
## Theme templates
|
||||||
|
|
||||||
|
Ready-to-use themes with bundled fonts are available in `templates/themes/`:
|
||||||
|
|
||||||
|
| Theme | Base | Primary Color | Fonts |
|
||||||
|
|-------|------|---------------|-------|
|
||||||
|
| **snowflake** | Light | `#29B5E8` (cyan) | Inter, JetBrains Mono |
|
||||||
|
| **dracula** | Dark | `#BD93F9` (purple) | Fira Sans, JetBrains Mono |
|
||||||
|
| **nord** | Dark | `#88C0D0` (frost blue) | Inter, JetBrains Mono |
|
||||||
|
| **stripe** | Light | `#635BFF` (indigo) | Inter, Source Code Pro |
|
||||||
|
| **solarized-light** | Light | `#268BD2` (blue) | Source Sans 3, Source Code Pro |
|
||||||
|
| **spotify** | Dark | `#1DB954` (green) | Inter, Fira Code |
|
||||||
|
| **github** | Light | `#0969DA` (blue) | Inter, JetBrains Mono |
|
||||||
|
| **minimal** | Dark | `#6366f1` (indigo) | Inter, JetBrains Mono |
|
||||||
|
|
||||||
|
Each theme uses Google Fonts for easy setup. See `templates/themes/README.md`.
|
||||||
|
|
||||||
|
## Related skills
|
||||||
|
|
||||||
|
- [improving-streamlit-design](../improving-streamlit-design/SKILL.md) - Visual polish with icons, badges, spacing
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Theming overview](https://docs.streamlit.io/develop/concepts/configuration/theming)
|
||||||
|
- [Colors and borders](https://docs.streamlit.io/develop/concepts/configuration/theming-customize-colors-and-borders)
|
||||||
|
- [Fonts](https://docs.streamlit.io/develop/concepts/configuration/theming-customize-fonts)
|
||||||
|
- [config.toml reference](https://docs.streamlit.io/develop/api-reference/configuration/config.toml)
|
||||||
|
- [st.context](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.context)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
---
|
||||||
|
name: displaying-streamlit-data
|
||||||
|
description: Displaying charts, dataframes, and metrics in Streamlit. Use when visualizing data, configuring dataframe columns, or adding sparklines to metrics. Covers native charts, Altair, and column configuration.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit charts & data
|
||||||
|
|
||||||
|
Present data clearly.
|
||||||
|
|
||||||
|
## Choosing display elements
|
||||||
|
|
||||||
|
| Element | Use Case |
|
||||||
|
|---------|----------|
|
||||||
|
| `st.dataframe` | Interactive exploration, sorting, filtering |
|
||||||
|
| `st.data_editor` | User-editable tables |
|
||||||
|
| `st.table` | Static display, no interaction needed |
|
||||||
|
| `st.metric` | KPIs with delta indicators |
|
||||||
|
| `st.json` | Structured data inspection |
|
||||||
|
|
||||||
|
## Native charts first
|
||||||
|
|
||||||
|
Prefer Streamlit's native charts for simple cases.
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.line_chart(df, x="date", y="revenue")
|
||||||
|
st.bar_chart(df, x="category", y="count")
|
||||||
|
st.scatter_chart(df, x="age", y="salary")
|
||||||
|
st.area_chart(df, x="date", y="value")
|
||||||
|
```
|
||||||
|
|
||||||
|
Native charts support additional parameters: `color` for series grouping, `stack` for bar/area stacking, `size` for scatter point sizing, `horizontal` for horizontal bars. See the [chart API reference](https://docs.streamlit.io/develop/api-reference/charts) for full options.
|
||||||
|
|
||||||
|
## Human-readable labels
|
||||||
|
|
||||||
|
Use clear labels—not column names or abbreviations. Skip `x_label`/`y_label` if the column names are already readable.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: cryptic column names without labels
|
||||||
|
st.line_chart(df, x="dt", y="rev")
|
||||||
|
|
||||||
|
# GOOD: readable columns, no labels needed
|
||||||
|
st.line_chart(df, x="date", y="revenue")
|
||||||
|
|
||||||
|
# GOOD: cryptic columns, add labels
|
||||||
|
st.line_chart(df, x="dt", y="rev", x_label="Date", y_label="Revenue")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Altair for complex charts
|
||||||
|
|
||||||
|
Use Altair when you need more control. Altair is bundled with Streamlit (no extra install), while Plotly requires an additional package. Pick one and stay consistent throughout your app.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
chart = alt.Chart(df).mark_line().encode(
|
||||||
|
x=alt.X("date:T", title="Date"),
|
||||||
|
y=alt.Y("revenue:Q", title="Revenue ($)"),
|
||||||
|
color="region:N"
|
||||||
|
)
|
||||||
|
st.altair_chart(chart)
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use Altair:**
|
||||||
|
- Custom axis formatting
|
||||||
|
- Multiple series with legends
|
||||||
|
- Interactive tooltips
|
||||||
|
- Layered visualizations
|
||||||
|
|
||||||
|
## Dataframe column configuration
|
||||||
|
|
||||||
|
Use `column_config` where it adds value—formatting currencies, showing progress bars, displaying links or images. Don't add config just for labels or tooltips that don't meaningfully improve readability. Works with both `st.dataframe` and `st.data_editor`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
column_config={
|
||||||
|
"revenue": st.column_config.NumberColumn(
|
||||||
|
"Revenue",
|
||||||
|
format="$%.2f"
|
||||||
|
),
|
||||||
|
"completion": st.column_config.ProgressColumn(
|
||||||
|
"Progress",
|
||||||
|
min_value=0,
|
||||||
|
max_value=100
|
||||||
|
),
|
||||||
|
"url": st.column_config.LinkColumn("Website"),
|
||||||
|
"logo": st.column_config.ImageColumn("Logo"),
|
||||||
|
"created_at": st.column_config.DatetimeColumn(
|
||||||
|
"Created",
|
||||||
|
format="MMM DD, YYYY"
|
||||||
|
),
|
||||||
|
"internal_id": None, # Hide non-essential columns
|
||||||
|
},
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on hiding columns:** Setting a column to `None` hides it from the UI, but the data is still sent to the frontend. For truly sensitive data, pre-filter the DataFrame before displaying.
|
||||||
|
|
||||||
|
**Dataframe best practices:**
|
||||||
|
- **Hide useless index:** `hide_index=True`
|
||||||
|
- **Or make index meaningful:** `df = df.set_index("customer_name")` before displaying
|
||||||
|
- **Hide internal/technical columns:** Set column to `None` in config (but pre-filter for sensitive data)
|
||||||
|
- **Use visual column types where they help:** sparklines for trends, progress bars for completion, images for logos
|
||||||
|
|
||||||
|
**Column types:**
|
||||||
|
- `AreaChartColumn` → Area sparklines
|
||||||
|
- `BarChartColumn` → Bar sparklines
|
||||||
|
- `CheckboxColumn` → Boolean as checkbox
|
||||||
|
- `DateColumn` → Date only (no time)
|
||||||
|
- `DatetimeColumn` → Dates with formatting
|
||||||
|
- `ImageColumn` → Images
|
||||||
|
- `JSONColumn` → Display JSON objects
|
||||||
|
- `LineChartColumn` → Sparkline charts
|
||||||
|
- `LinkColumn` → Clickable links
|
||||||
|
- `ListColumn` → Display lists/arrays
|
||||||
|
- `MultiselectColumn` → Multi-value selection
|
||||||
|
- `NumberColumn` → Numbers with formatting
|
||||||
|
- `ProgressColumn` → Progress bars
|
||||||
|
- `SelectboxColumn` → Editable dropdown
|
||||||
|
- `TextColumn` → Text with formatting
|
||||||
|
- `TimeColumn` → Time only (no date)
|
||||||
|
|
||||||
|
## Pinned columns
|
||||||
|
|
||||||
|
Keep important columns visible while scrolling horizontally:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
column_config={
|
||||||
|
"Title": st.column_config.TextColumn(pinned=True), # Always visible
|
||||||
|
"Rating": st.column_config.ProgressColumn(min_value=0, max_value=10),
|
||||||
|
},
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data editor
|
||||||
|
|
||||||
|
Use `st.data_editor` when users need to edit data directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
edited_df = st.data_editor(
|
||||||
|
df,
|
||||||
|
num_rows="dynamic", # Allow adding/deleting rows
|
||||||
|
column_config={
|
||||||
|
"status": st.column_config.SelectboxColumn(
|
||||||
|
"Status",
|
||||||
|
options=["pending", "approved", "rejected"]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# React to edits
|
||||||
|
if not edited_df.equals(df):
|
||||||
|
save_changes(edited_df)
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON display
|
||||||
|
|
||||||
|
For structured data inspection. Accepts dicts, lists, or any JSON-serializable object:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.json({"name": "John", "scores": [95, 87, 92]})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sparklines in metrics
|
||||||
|
|
||||||
|
Add `chart_data` and `chart_type` to metrics for visual context.
|
||||||
|
|
||||||
|
```python
|
||||||
|
values = [700, 720, 715, 740, 762, 755, 780]
|
||||||
|
|
||||||
|
st.metric(
|
||||||
|
label="Developers",
|
||||||
|
value="762k",
|
||||||
|
delta="-7.42% (MoM)",
|
||||||
|
delta_color="inverse",
|
||||||
|
chart_data=values,
|
||||||
|
chart_type="line" # or "bar"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Sparklines only show y-values and ignore x-axis spacing. Use them for evenly-spaced data (like daily or weekly snapshots). For irregularly-spaced time series, use a proper chart instead.
|
||||||
|
|
||||||
|
See `building-streamlit-dashboards` for composing metrics into dashboard layouts.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.dataframe](https://docs.streamlit.io/develop/api-reference/data/st.dataframe)
|
||||||
|
- [st.data_editor](https://docs.streamlit.io/develop/api-reference/data/st.data_editor)
|
||||||
|
- [st.column_config](https://docs.streamlit.io/develop/api-reference/data/st.column_config)
|
||||||
|
- [st.metric](https://docs.streamlit.io/develop/api-reference/data/st.metric)
|
||||||
|
- [st.json](https://docs.streamlit.io/develop/api-reference/data/st.json)
|
||||||
|
- [st.line_chart](https://docs.streamlit.io/develop/api-reference/charts/st.line_chart)
|
||||||
|
- [st.bar_chart](https://docs.streamlit.io/develop/api-reference/charts/st.bar_chart)
|
||||||
|
- [st.altair_chart](https://docs.streamlit.io/develop/api-reference/charts/st.altair_chart)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
---
|
||||||
|
name: improving-streamlit-design
|
||||||
|
description: Improving visual design in Streamlit apps. Use when polishing apps with icons, badges, spacing, or text styling. Covers Material icons, badge syntax, divider alternatives, and text casing conventions.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit visual design
|
||||||
|
|
||||||
|
Small touches that make apps feel polished.
|
||||||
|
|
||||||
|
**Related skills:** Visual design works hand-in-hand with other skills:
|
||||||
|
- `choosing-streamlit-selection-widgets` → Choosing the right widget (segmented control, pills, toggle)
|
||||||
|
- `displaying-streamlit-data` → Column config, sparklines, bordered metrics
|
||||||
|
- `using-streamlit-layouts` → Containers, alignment, dashboard cards
|
||||||
|
|
||||||
|
## Page config
|
||||||
|
|
||||||
|
Set browser tab title, icon, and layout. Place this at the top of your script to avoid visual blinking:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="My Dashboard",
|
||||||
|
page_icon=":material/analytics:",
|
||||||
|
layout="wide", # Use "wide" for dashboards with lots of data
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout options:**
|
||||||
|
- `layout="centered"` (default) → Best for most apps, content is constrained to a readable width
|
||||||
|
- `layout="wide"` → Full-width, good for dashboards and data-heavy apps
|
||||||
|
|
||||||
|
## App logo
|
||||||
|
|
||||||
|
Add a logo to the sidebar/header:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.logo("logo.png")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icons over emojis
|
||||||
|
|
||||||
|
Use Material icons for a cleaner, more professional look.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GOOD: Material icons
|
||||||
|
st.markdown(":material/settings:")
|
||||||
|
st.markdown(":material/calendar_today:")
|
||||||
|
st.markdown(":material/dashboard:")
|
||||||
|
st.markdown(":material/person:")
|
||||||
|
|
||||||
|
# SPARINGLY: Emojis for special occasions
|
||||||
|
st.markdown("Celebration! 🎉")
|
||||||
|
```
|
||||||
|
|
||||||
|
Format: `:material/icon_name:`
|
||||||
|
|
||||||
|
Find icons: https://fonts.google.com/icons
|
||||||
|
|
||||||
|
**Popular icons by category:**
|
||||||
|
|
||||||
|
| Category | Icons |
|
||||||
|
|----------|-------|
|
||||||
|
| Navigation | `home`, `arrow_back`, `menu`, `settings`, `search` |
|
||||||
|
| Actions | `send`, `play_arrow`, `refresh`, `download`, `upload`, `save`, `delete`, `edit` |
|
||||||
|
| Status | `check_circle`, `error`, `warning`, `info`, `pending` |
|
||||||
|
| Data | `table_chart`, `bar_chart`, `analytics`, `query_stats`, `database` |
|
||||||
|
| Content | `chat`, `code`, `description`, `article`, `folder` |
|
||||||
|
| UI | `visibility`, `build`, `tune`, `filter_list` |
|
||||||
|
|
||||||
|
## Badges for status
|
||||||
|
|
||||||
|
For standalone badges:
|
||||||
|
```python
|
||||||
|
st.badge("Active", icon=":material/check:", color="green")
|
||||||
|
st.badge("Pending", icon=":material/schedule:", color="orange")
|
||||||
|
st.badge("Deprecated", color="red")
|
||||||
|
```
|
||||||
|
|
||||||
|
For inline badges in text:
|
||||||
|
```python
|
||||||
|
st.markdown("""
|
||||||
|
:green-badge[Active] :orange-badge[Pending] :red-badge[Deprecated] :blue-badge[New]
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid the old verbose syntax:
|
||||||
|
```python
|
||||||
|
# OLD (still works but cluttered)
|
||||||
|
st.markdown(":orange-background[:orange[Pending]]")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spacing: remove dividers
|
||||||
|
|
||||||
|
Dividers (`st.divider()` or `---`) look heavy. Just remove them—Streamlit's default spacing is usually enough.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD
|
||||||
|
st.header("Section 1")
|
||||||
|
st.write("Content")
|
||||||
|
st.divider() # Too heavy
|
||||||
|
st.header("Section 2")
|
||||||
|
|
||||||
|
# GOOD
|
||||||
|
st.header("Section 1")
|
||||||
|
st.write("Content")
|
||||||
|
st.header("Section 2")
|
||||||
|
```
|
||||||
|
|
||||||
|
If you genuinely need spacing:
|
||||||
|
```python
|
||||||
|
st.space("small") # Small gap
|
||||||
|
st.space("medium") # Medium gap
|
||||||
|
st.space("large") # Large gap
|
||||||
|
st.space(50) # Custom pixels for fine-tuning
|
||||||
|
```
|
||||||
|
|
||||||
|
**Don't** systematically replace dividers with `st.space()`—it can look weird too.
|
||||||
|
|
||||||
|
## Sentence casing
|
||||||
|
|
||||||
|
Use sentence casing for titles and labels. Title Case Feels Shouty.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GOOD
|
||||||
|
st.title("Upload your data")
|
||||||
|
st.selectbox("Select a region", options)
|
||||||
|
st.button("Save changes")
|
||||||
|
|
||||||
|
# BAD
|
||||||
|
st.title("Upload Your Data")
|
||||||
|
st.selectbox("Select A Region", options)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caption over info
|
||||||
|
|
||||||
|
`st.info()` is too heavy for simple informational text.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GOOD: Lighter
|
||||||
|
st.caption("Data last updated 5 minutes ago")
|
||||||
|
|
||||||
|
# BAD: Too heavy
|
||||||
|
st.info("Data last updated 5 minutes ago")
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use what:**
|
||||||
|
- `st.caption` → Simple info, metadata, timestamps
|
||||||
|
- `st.info` → Important instructions
|
||||||
|
- `st.warning` → Caution, potential issues
|
||||||
|
- `st.error` → Errors that block progress
|
||||||
|
- `st.success` → Confirmation of action
|
||||||
|
- `st.toast` → Lightweight confirmation that auto-dismisses
|
||||||
|
|
||||||
|
## Text alignment
|
||||||
|
|
||||||
|
Use `text_alignment` for text elements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.title("Centered title", text_alignment="center")
|
||||||
|
st.write("Right aligned", text_alignment="right")
|
||||||
|
st.caption("Justified text", text_alignment="justify")
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `"left"` (default), `"center"`, `"right"`, `"justify"`
|
||||||
|
|
||||||
|
**Note:** `horizontal_alignment` on containers positions elements but also sets their `text_alignment`. If you need different text alignment within a horizontally-aligned container, override `text_alignment` on the text element itself.
|
||||||
|
|
||||||
|
## Icons in callouts and expanders
|
||||||
|
|
||||||
|
Material icons can make callouts and expanders look nicer:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.info("Processing complete", icon=":material/check_circle:")
|
||||||
|
st.warning("Rate limit approaching", icon=":material/warning:")
|
||||||
|
st.error("Connection failed", icon=":material/error:")
|
||||||
|
st.success("Saved!", icon=":material/thumb_up:")
|
||||||
|
|
||||||
|
with st.expander("Settings", icon=":material/settings:"):
|
||||||
|
st.write("Configure your preferences")
|
||||||
|
```
|
||||||
|
|
||||||
|
Other elements like `st.button` and `st.tabs` also support icons—worth considering when it adds clarity.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.set_page_config](https://docs.streamlit.io/develop/api-reference/configuration/st.set_page_config)
|
||||||
|
- [st.logo](https://docs.streamlit.io/develop/api-reference/media/st.logo)
|
||||||
|
- [st.badge](https://docs.streamlit.io/develop/api-reference/text/st.badge)
|
||||||
|
- [st.space](https://docs.streamlit.io/develop/api-reference/layout/st.space)
|
||||||
|
- [st.markdown](https://docs.streamlit.io/develop/api-reference/text/st.markdown)
|
||||||
|
- [st.toast](https://docs.streamlit.io/develop/api-reference/status/st.toast)
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
---
|
||||||
|
name: optimizing-streamlit-performance
|
||||||
|
description: Optimizing Streamlit app performance. Use when apps are slow, rerunning too often, or loading heavy content. Covers caching, fragments, and static vs dynamic widget choices.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit performance
|
||||||
|
|
||||||
|
Performance is the biggest win. Without caching and fragments, your app reruns everything on every interaction.
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
### @st.cache_data for data
|
||||||
|
|
||||||
|
Use for any function that loads or computes data.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Recomputes on every rerun
|
||||||
|
def load_data(path):
|
||||||
|
return pd.read_csv(path)
|
||||||
|
|
||||||
|
# GOOD: Cached
|
||||||
|
@st.cache_data
|
||||||
|
def load_data(path):
|
||||||
|
return pd.read_csv(path)
|
||||||
|
```
|
||||||
|
|
||||||
|
### @st.cache_resource for connections
|
||||||
|
|
||||||
|
Use for connections, API clients, ML models—objects that can't be serialized.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@st.cache_resource
|
||||||
|
def get_connection():
|
||||||
|
return st.connection("snowflake")
|
||||||
|
|
||||||
|
@st.cache_resource
|
||||||
|
def load_model():
|
||||||
|
return torch.load("model.pt")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical warning:** Never mutate `@st.cache_resource` returns—changes affect all users:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Mutating shared resource
|
||||||
|
@st.cache_resource
|
||||||
|
def get_config():
|
||||||
|
return {"setting": "default"}
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
config["setting"] = "custom" # Affects ALL users!
|
||||||
|
|
||||||
|
# GOOD: Copy before modifying
|
||||||
|
config = get_config().copy()
|
||||||
|
config["setting"] = "custom"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cleanup with `on_release`:** Clean up resources when evicted from cache:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def cleanup_connection(conn):
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@st.cache_resource(on_release=cleanup_connection)
|
||||||
|
def get_database():
|
||||||
|
return create_connection()
|
||||||
|
```
|
||||||
|
|
||||||
|
### TTL for fresh data
|
||||||
|
|
||||||
|
```python
|
||||||
|
@st.cache_data(ttl="5m") # 5 minutes
|
||||||
|
def get_metrics():
|
||||||
|
return api.fetch()
|
||||||
|
|
||||||
|
@st.cache_data(ttl="1h") # 1 hour
|
||||||
|
def load_reference_data():
|
||||||
|
return pd.read_csv("large_reference.csv")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guidelines:**
|
||||||
|
- Real-time dashboards → `ttl="1m"` or less
|
||||||
|
- Metrics/reports → `ttl="5m"` to `ttl="15m"`
|
||||||
|
- Reference data → `ttl="1h"` or more
|
||||||
|
- Static data → No TTL
|
||||||
|
|
||||||
|
### Prevent unbounded cache growth
|
||||||
|
|
||||||
|
**Important:** Caches without `ttl` or `max_entries` can grow indefinitely and cause memory issues. For any cached function that stores changing objects (user-specific data, parameterized queries), set limits:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Unbounded cache - memory will grow indefinitely
|
||||||
|
@st.cache_data
|
||||||
|
def get_user_data(user_id):
|
||||||
|
return fetch_user(user_id)
|
||||||
|
|
||||||
|
# GOOD: Bounded cache with TTL
|
||||||
|
@st.cache_data(ttl="1h")
|
||||||
|
def get_user_data(user_id):
|
||||||
|
return fetch_user(user_id)
|
||||||
|
|
||||||
|
# GOOD: Bounded cache with max entries
|
||||||
|
@st.cache_data(max_entries=100)
|
||||||
|
def get_user_data(user_id):
|
||||||
|
return fetch_user(user_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `ttl` for time-based expiration OR `max_entries` for size-based limits. You usually don't need both.
|
||||||
|
|
||||||
|
### Caching anti-patterns
|
||||||
|
|
||||||
|
**Don't cache functions that read widgets:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Widget inside cached function
|
||||||
|
@st.cache_data
|
||||||
|
def filtered_data():
|
||||||
|
query = st.text_input("Query") # Widget inside cached function!
|
||||||
|
return df[df["name"].str.contains(query)]
|
||||||
|
|
||||||
|
# GOOD: Pass widget values as parameters
|
||||||
|
@st.cache_data
|
||||||
|
def filtered_data(query: str):
|
||||||
|
return df[df["name"].str.contains(query)]
|
||||||
|
|
||||||
|
query = st.text_input("Query")
|
||||||
|
result = filtered_data(query)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache at the right granularity:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Caching too much - new cache entry per filter value
|
||||||
|
@st.cache_data
|
||||||
|
def get_and_filter_data(filter_value):
|
||||||
|
data = load_all_data() # Expensive!
|
||||||
|
return data[data["col"] == filter_value]
|
||||||
|
|
||||||
|
# GOOD: Cache the expensive part, filter separately
|
||||||
|
@st.cache_data(ttl="1h")
|
||||||
|
def load_all_data():
|
||||||
|
return fetch_from_database()
|
||||||
|
|
||||||
|
data = load_all_data()
|
||||||
|
filtered = data[data["col"] == filter_value]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fragments
|
||||||
|
|
||||||
|
Use `@st.fragment` to isolate reruns for self-contained UI pieces.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Full app reruns
|
||||||
|
st.metric("Users", get_count())
|
||||||
|
if st.button("Refresh"):
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# GOOD: Only fragment reruns
|
||||||
|
@st.fragment
|
||||||
|
def live_metrics():
|
||||||
|
st.metric("Users", get_count())
|
||||||
|
st.button("Refresh")
|
||||||
|
|
||||||
|
live_metrics()
|
||||||
|
```
|
||||||
|
|
||||||
|
For auto-refreshing metrics, use `run_every`:
|
||||||
|
```python
|
||||||
|
@st.fragment(run_every="30s")
|
||||||
|
def auto_refresh_metrics():
|
||||||
|
st.metric("Users", get_count())
|
||||||
|
|
||||||
|
auto_refresh_metrics()
|
||||||
|
```
|
||||||
|
|
||||||
|
Use for: live metrics, refresh buttons, interactive charts that don't affect global state.
|
||||||
|
|
||||||
|
## Forms to batch interactions
|
||||||
|
|
||||||
|
By default, every widget interaction triggers a full rerun. Use `st.form` to batch multiple inputs and only rerun on submit.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Reruns on every keystroke and selection
|
||||||
|
name = st.text_input("Name")
|
||||||
|
email = st.text_input("Email")
|
||||||
|
role = st.selectbox("Role", ["Admin", "User"])
|
||||||
|
|
||||||
|
# GOOD: Single rerun when user clicks Submit
|
||||||
|
with st.form("user_form"):
|
||||||
|
name = st.text_input("Name")
|
||||||
|
email = st.text_input("Email")
|
||||||
|
role = st.selectbox("Role", ["Admin", "User"])
|
||||||
|
submitted = st.form_submit_button("Submit")
|
||||||
|
|
||||||
|
if submitted:
|
||||||
|
save_user(name, email, role)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `border=False` for seamless inline forms that don't look like forms:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.form("search", border=False):
|
||||||
|
with st.container(horizontal=True):
|
||||||
|
query = st.text_input("Search", label_visibility="collapsed")
|
||||||
|
st.form_submit_button(":material/search:")
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use forms:**
|
||||||
|
- Multiple related inputs (signup, filters, settings)
|
||||||
|
- Text inputs where typing triggers expensive operations
|
||||||
|
- Any UI where "submit" semantics make sense
|
||||||
|
|
||||||
|
**When NOT to use forms:** If inputs depend on each other (e.g., selecting a country should update available cities), forms won't work since there's no rerun until submit.
|
||||||
|
|
||||||
|
## Conditional rendering
|
||||||
|
|
||||||
|
**This is critical and often missed.**
|
||||||
|
|
||||||
|
Layout containers like `st.tabs`, `st.expander`, and `st.popover` always render all their content, even when hidden or collapsed.
|
||||||
|
|
||||||
|
To render content only when needed, use elements like `st.segmented_control`, `st.toggle`, or `@st.dialog` with conditional logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Heavy content loads even when tab not visible
|
||||||
|
tab1, tab2 = st.tabs(["Light", "Heavy"])
|
||||||
|
with tab2:
|
||||||
|
expensive_chart() # Always computed!
|
||||||
|
|
||||||
|
# GOOD: Content only loads when selected
|
||||||
|
view = st.segmented_control("View", ["Light", "Heavy"])
|
||||||
|
if view == "Heavy":
|
||||||
|
expensive_chart() # Only computed when selected
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Expander content always loads
|
||||||
|
with st.expander("Advanced options"):
|
||||||
|
heavy_computation() # Runs even when collapsed!
|
||||||
|
|
||||||
|
# GOOD: Toggle controls loading
|
||||||
|
if st.toggle("Show advanced options"):
|
||||||
|
heavy_computation() # Only runs when toggled on
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-computation
|
||||||
|
|
||||||
|
Move expensive work outside the main flow:
|
||||||
|
- Compute aggregations in SQL/dbt, not Python
|
||||||
|
- Pre-compute metrics in scheduled jobs
|
||||||
|
- Use materialized views for complex queries
|
||||||
|
|
||||||
|
## Large data handling
|
||||||
|
|
||||||
|
### For datasets under ~100M rows
|
||||||
|
|
||||||
|
```python
|
||||||
|
@st.cache_data
|
||||||
|
def load_data():
|
||||||
|
return pd.read_parquet("large_file.parquet")
|
||||||
|
```
|
||||||
|
|
||||||
|
### For very large datasets (over ~100M rows)
|
||||||
|
|
||||||
|
> **Note:** This is only an escape hatch when serialization becomes too slow. In most cases, data this large shouldn't be loaded entirely into memory—prefer using a database that queries and loads data on demand.
|
||||||
|
|
||||||
|
`@st.cache_data` uses pickle which slows with huge data. Use `@st.cache_resource` instead:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@st.cache_resource # No serialization overhead
|
||||||
|
def load_huge_data():
|
||||||
|
return pd.read_parquet("huge_file.parquet")
|
||||||
|
|
||||||
|
# WARNING: Don't mutate the returned DataFrame!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sampling for exploration
|
||||||
|
|
||||||
|
When exploring large datasets, load a random sample instead of the full data:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@st.cache_data(ttl="1h")
|
||||||
|
def load_sample(n=10000):
|
||||||
|
df = pd.read_parquet("huge.parquet")
|
||||||
|
return df.sample(n=n)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multithreading
|
||||||
|
|
||||||
|
Custom threads cannot call Streamlit commands (no session context).
|
||||||
|
|
||||||
|
```python
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def fetch_in_background(url, results, index):
|
||||||
|
results[index] = requests.get(url).json() # No st.* calls!
|
||||||
|
|
||||||
|
# Collect results, then display in main thread
|
||||||
|
results = [None] * len(urls)
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=fetch_in_background, args=(url, results, i))
|
||||||
|
for i, url in enumerate(urls)
|
||||||
|
]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
# Now display in main thread
|
||||||
|
for result in results:
|
||||||
|
st.write(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer alternatives when possible:**
|
||||||
|
- `@st.cache_data` for expensive computations
|
||||||
|
- `@st.fragment(run_every="5s")` for periodic updates
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Caching overview](https://docs.streamlit.io/develop/concepts/architecture/caching)
|
||||||
|
- [st.cache_data](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.cache_data)
|
||||||
|
- [st.cache_resource](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.cache_resource)
|
||||||
|
- [st.fragment](https://docs.streamlit.io/develop/api-reference/execution-flow/st.fragment)
|
||||||
|
- [st.form](https://docs.streamlit.io/develop/api-reference/execution-flow/st.form)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: organizing-streamlit-code
|
||||||
|
description: Organizing Streamlit code for maintainability. Use when structuring apps with separate modules and utilities. Covers separation of concerns, keeping UI code clean, and import patterns.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit code organization
|
||||||
|
|
||||||
|
For most simple apps, keep everything in one file—it's cleaner and more straightforward. The app file should read like a normal Python script for data processing, with a few Streamlit commands sprinkled in.
|
||||||
|
|
||||||
|
Name the main file `streamlit_app.py` (Streamlit's default).
|
||||||
|
|
||||||
|
## When to split
|
||||||
|
|
||||||
|
**Keep in one file (most apps):**
|
||||||
|
- Apps under ~1000 lines
|
||||||
|
- One-off scripts and prototypes
|
||||||
|
- Apps where logic is straightforward
|
||||||
|
|
||||||
|
**Consider splitting when:**
|
||||||
|
- Data processing is complex (50+ lines of non-UI code)
|
||||||
|
- Multiple pages share logic
|
||||||
|
- You want to test business logic separately
|
||||||
|
|
||||||
|
If splitting makes sense, here's how to organize it.
|
||||||
|
|
||||||
|
## Directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── streamlit_app.py # Main entry point
|
||||||
|
├── app_pages/ # Page UI modules
|
||||||
|
│ ├── dashboard.py
|
||||||
|
│ └── settings.py
|
||||||
|
└── utils/ # Business logic & helpers
|
||||||
|
├── data.py
|
||||||
|
└── api.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Separating UI from logic
|
||||||
|
|
||||||
|
When you do split, keep Streamlit files focused on UI and move complex logic to utility modules:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# streamlit_app.py - UI-focused
|
||||||
|
import streamlit as st
|
||||||
|
from utils.data import load_sales_data, compute_metrics
|
||||||
|
|
||||||
|
st.title("Sales Dashboard")
|
||||||
|
|
||||||
|
start = st.date_input("Start")
|
||||||
|
end = st.date_input("End")
|
||||||
|
|
||||||
|
data = load_sales_data(start, end)
|
||||||
|
metrics = compute_metrics(data)
|
||||||
|
|
||||||
|
st.metric("Revenue", f"${metrics['revenue']:,.0f}")
|
||||||
|
st.dataframe(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Avoid if __name__ == "__main__"
|
||||||
|
|
||||||
|
Streamlit apps run the entire file on each interaction. Don't use the main guard in Streamlit files.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD - don't do this in streamlit_app.py or pages
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
# GOOD - just put the code directly
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.title("My App")
|
||||||
|
```
|
||||||
|
|
||||||
|
The main guard is fine in utility modules for quick testing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# utils/data.py
|
||||||
|
def load_data(path):
|
||||||
|
...
|
||||||
|
|
||||||
|
# Optional: test this module directly with `python utils/data.py`
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(load_data("test.csv"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Multipage apps](https://docs.streamlit.io/develop/concepts/multipage-apps)
|
||||||
|
- [st.cache_data](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.cache_data)
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
name: setting-up-streamlit-environment
|
||||||
|
description: Setting up Python environments for Streamlit apps. Use when creating a new project or managing dependencies. Covers uv for dependency management and running apps.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit environment
|
||||||
|
|
||||||
|
Use whatever dependency management the project already has (pip, poetry, conda, etc.). If starting fresh and uv is available, it's a good default—fast, reliable, and creates isolated environments automatically.
|
||||||
|
|
||||||
|
If uv is not installed, ask the user before installing it.
|
||||||
|
|
||||||
|
## CRITICAL: Always Use Latest Streamlit
|
||||||
|
|
||||||
|
**Always specify `streamlit>=1.53.0`** (or latest) in dependencies. Many Streamlit features and patterns in these skills require recent versions. Older streamlit versions will cause errors with:
|
||||||
|
- Material icons (`:material/icon_name:`)
|
||||||
|
- `st.pills()`, `st.segmented_control()`
|
||||||
|
- Modern caching decorators
|
||||||
|
- Navigation APIs
|
||||||
|
|
||||||
|
When setting up a new project or fixing an existing one, **always check and update the streamlit version**.
|
||||||
|
|
||||||
|
## Using uv
|
||||||
|
|
||||||
|
If uv is available, here's how to set up a Streamlit project.
|
||||||
|
|
||||||
|
### Quick start (venv only)
|
||||||
|
|
||||||
|
For simple apps, just create a virtual environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv
|
||||||
|
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||||
|
uv pip install streamlit
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run streamlit_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full project setup
|
||||||
|
|
||||||
|
For larger projects or when you need reproducible builds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv init my-streamlit-app
|
||||||
|
cd my-streamlit-app
|
||||||
|
uv add streamlit
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `pyproject.toml` with dependencies
|
||||||
|
- `uv.lock` for reproducible builds
|
||||||
|
- `.venv/` virtual environment
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run streamlit run streamlit_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## With options
|
||||||
|
|
||||||
|
Avoid setting options unless you have a specific reason:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run streamlit_app.py --server.headless true # Only for automated/CI environments
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With venv approach
|
||||||
|
uv pip install plotly snowflake-connector-python
|
||||||
|
|
||||||
|
# With full project (uv init)
|
||||||
|
uv add plotly snowflake-connector-python
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
Keep it simple. For most apps:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-streamlit-app/
|
||||||
|
├── .venv/
|
||||||
|
└── streamlit_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Only add more when needed:
|
||||||
|
- `app_pages/` → Only for multi-page apps
|
||||||
|
- `.streamlit/config.toml` → Only if customizing theme or settings
|
||||||
|
- `.streamlit/secrets.toml` → Only if using secrets (add to `.gitignore`)
|
||||||
|
- `pyproject.toml` → Only if using `uv init` for reproducible builds
|
||||||
|
|
||||||
|
## Convention
|
||||||
|
|
||||||
|
Name your main file `streamlit_app.py` for consistency. This is what Streamlit expects by default.
|
||||||
|
|
||||||
|
**What goes in the main module:**
|
||||||
|
- When using navigation: it's a router that defines pages and runs them
|
||||||
|
- When there's no navigation: it's the home page with your main content
|
||||||
|
|
||||||
|
## pyproject.toml Example
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "my-streamlit-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
"plotly>=5.0.0",
|
||||||
|
"snowflake-connector-python>=3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
dev-dependencies = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [uv documentation](https://docs.astral.sh/uv/)
|
||||||
|
- [Streamlit installation](https://docs.streamlit.io/get-started/installation)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
name: using-streamlit-cli
|
||||||
|
description: Documents Streamlit CLI commands for running apps, managing configuration, and diagnostics. Use when starting Streamlit apps, configuring runtime options, or troubleshooting CLI issues.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using the Streamlit CLI
|
||||||
|
|
||||||
|
The Streamlit CLI is the primary tool for running Streamlit applications and managing configuration. This skill covers all essential commands and configuration options.
|
||||||
|
|
||||||
|
## Running Streamlit apps
|
||||||
|
|
||||||
|
### Basic syntax
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run [<entrypoint>] [-- config options] [script args]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entrypoint options
|
||||||
|
|
||||||
|
| Argument | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| (none) | Looks for `streamlit_app.py` in current directory |
|
||||||
|
| Directory path | Runs `streamlit_app.py` within that directory |
|
||||||
|
| File path | Runs the specified file directly |
|
||||||
|
| URL | Runs a remote script (e.g., from GitHub) |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run default app in current directory
|
||||||
|
streamlit run
|
||||||
|
|
||||||
|
# Run a specific file
|
||||||
|
streamlit run app.py
|
||||||
|
|
||||||
|
# Run from a URL
|
||||||
|
streamlit run https://raw.githubusercontent.com/streamlit/demo-uber-nyc-pickups/master/streamlit_app.py
|
||||||
|
|
||||||
|
# Alternative: run as Python module (useful for IDE configuration)
|
||||||
|
python -m streamlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running with `uv` (recommended)
|
||||||
|
|
||||||
|
Use `uv run` to run Streamlit in a virtual environment with automatic dependency management:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with uv (automatically uses/creates virtual environment)
|
||||||
|
uv run streamlit run app.py
|
||||||
|
|
||||||
|
# With configuration options
|
||||||
|
uv run streamlit run app.py --server.headless=true
|
||||||
|
|
||||||
|
# With script arguments
|
||||||
|
uv run streamlit run app.py -- arg1 arg2
|
||||||
|
```
|
||||||
|
|
||||||
|
Using `uv run` is the recommended approach because it:
|
||||||
|
- Automatically manages virtual environments
|
||||||
|
- Resolves and installs dependencies from `pyproject.toml`
|
||||||
|
- Ensures reproducible environments across machines
|
||||||
|
- Avoids manual activation/deactivation of virtual environments
|
||||||
|
|
||||||
|
## Setting configuration with `streamlit run`
|
||||||
|
|
||||||
|
Configuration options follow the pattern `--<section>.<option>=<value>` and must come after the script name.
|
||||||
|
|
||||||
|
> **Recommendation:** For persistent configuration, use `.streamlit/config.toml` in your project directory instead of command-line flags. This keeps your run command simple and makes configuration easier to manage and share with your team.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run app.py --server.port=8080
|
||||||
|
streamlit run app.py --server.headless=true
|
||||||
|
streamlit run app.py --server.runOnSave=true
|
||||||
|
streamlit run app.py --server.address=0.0.0.0
|
||||||
|
streamlit run app.py --client.showErrorDetails=false
|
||||||
|
streamlit run app.py --theme.primaryColor=blue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combining multiple options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run app.py \
|
||||||
|
--server.port=8080 \
|
||||||
|
--server.headless=true \
|
||||||
|
--theme.primaryColor=blue \
|
||||||
|
--client.showErrorDetails=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Passing arguments to your script
|
||||||
|
|
||||||
|
Script arguments come after configuration options. Use `sys.argv` to access them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run app.py -- arg1 arg2 "arg with spaces"
|
||||||
|
```
|
||||||
|
|
||||||
|
In your script:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# sys.argv[0] = script path
|
||||||
|
# sys.argv[1:] = your arguments
|
||||||
|
args = sys.argv[1:]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other CLI commands
|
||||||
|
|
||||||
|
### View configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show all current configuration settings
|
||||||
|
streamlit config show
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clear all cached data from disk
|
||||||
|
streamlit cache clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diagnostics and help
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show installed version
|
||||||
|
streamlit version
|
||||||
|
|
||||||
|
# List all available commands
|
||||||
|
streamlit help
|
||||||
|
|
||||||
|
# Open documentation in browser
|
||||||
|
streamlit docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project scaffolding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create starter files for a new project
|
||||||
|
streamlit init
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demo app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch the Streamlit demo application
|
||||||
|
streamlit hello
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration precedence
|
||||||
|
|
||||||
|
Configuration can be set in multiple places. Order of precedence (highest to lowest):
|
||||||
|
|
||||||
|
1. **Command-line flags** (`--server.port=8080`)
|
||||||
|
2. **Environment variables** (`STREAMLIT_SERVER_PORT=8080`)
|
||||||
|
3. **Local config** (`.streamlit/config.toml` in project directory)
|
||||||
|
4. **Global config** (`~/.streamlit/config.toml`)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Run your app](https://docs.streamlit.io/develop/concepts/architecture/run-your-app) - Concepts and methods for running Streamlit apps
|
||||||
|
- [config.toml](https://docs.streamlit.io/develop/api-reference/configuration/config.toml) - Complete configuration options reference
|
||||||
|
- [CLI reference](https://docs.streamlit.io/develop/api-reference/cli) - Full CLI command documentation
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
name: using-streamlit-custom-components
|
||||||
|
description: Using third-party Streamlit custom components. Use when extending Streamlit with community packages. Covers installation, popular custom components, and when to use them.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit custom components
|
||||||
|
|
||||||
|
Extend Streamlit with third-party custom components from the community.
|
||||||
|
|
||||||
|
## What are custom components?
|
||||||
|
|
||||||
|
Custom components are standalone Python libraries that add features not in Streamlit's core API. They're built by the community and can be installed like any Python package.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install using the PyPI package name (not the repo name—they can differ):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add <pypi-package-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then import according to the component's documentation. The import name often differs from the package name too.
|
||||||
|
|
||||||
|
## Use with caution
|
||||||
|
|
||||||
|
Components are not maintained by Streamlit. Before adopting:
|
||||||
|
|
||||||
|
- **Check maintenance** - Is it actively maintained? Recent commits?
|
||||||
|
- **Check compatibility** - Does it work with your Streamlit version?
|
||||||
|
- **Check popularity** - GitHub stars, downloads, community usage
|
||||||
|
- **Consider alternatives** - Can you achieve this with core Streamlit?
|
||||||
|
|
||||||
|
Custom components can break when Streamlit updates, so prefer core features when possible.
|
||||||
|
|
||||||
|
## Popular custom components
|
||||||
|
|
||||||
|
### streamlit-keyup
|
||||||
|
|
||||||
|
Text input that fires on every keystroke instead of waiting for enter/blur. Useful for live search.
|
||||||
|
|
||||||
|
- **Repo:** https://github.com/blackary/streamlit-keyup
|
||||||
|
- **Docs:** https://pypi.org/project/streamlit-keyup/
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add streamlit-keyup
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from st_keyup import st_keyup
|
||||||
|
|
||||||
|
query = st_keyup("Search", debounce=300) # 300ms debounce
|
||||||
|
filtered = df[df["name"].str.contains(query, case=False)]
|
||||||
|
st.dataframe(filtered)
|
||||||
|
```
|
||||||
|
|
||||||
|
### streamlit-bokeh
|
||||||
|
|
||||||
|
Official replacement for `st.bokeh_chart` (removed from Streamlit API). Maintained by Streamlit.
|
||||||
|
|
||||||
|
- **Repo:** https://github.com/streamlit/streamlit-bokeh
|
||||||
|
- **Docs:** https://pypi.org/project/streamlit-bokeh/
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add streamlit-bokeh
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bokeh.plotting import figure
|
||||||
|
from streamlit_bokeh import streamlit_bokeh
|
||||||
|
|
||||||
|
p = figure(title="Simple Line", x_axis_label="x", y_axis_label="y")
|
||||||
|
p.line([1, 2, 3, 4, 5], [6, 7, 2, 4, 5], line_width=2)
|
||||||
|
streamlit_bokeh(p)
|
||||||
|
```
|
||||||
|
|
||||||
|
### streamlit-aggrid
|
||||||
|
|
||||||
|
Interactive dataframes with sorting, filtering, cell editing, grouping, and pivoting. Use when you need customization beyond what `st.dataframe` and `st.data_editor` offer.
|
||||||
|
|
||||||
|
- **Repo:** https://github.com/PablocFonseca/streamlit-aggrid
|
||||||
|
- **Docs:** https://pypi.org/project/streamlit-aggrid/
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add streamlit-aggrid
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from st_aggrid import AgGrid
|
||||||
|
|
||||||
|
AgGrid(df, editable=True, filter=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use aggrid over st.dataframe:**
|
||||||
|
- Interactive row grouping and pivoting
|
||||||
|
- Advanced filtering and sorting UI
|
||||||
|
- Complex cell editing workflows
|
||||||
|
- Custom cell renderers
|
||||||
|
|
||||||
|
### streamlit-folium
|
||||||
|
|
||||||
|
Interactive maps powered by Folium.
|
||||||
|
|
||||||
|
- **Repo:** https://github.com/randyzwitch/streamlit-folium
|
||||||
|
- **Docs:** https://folium.streamlit.app/
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add streamlit-folium
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import folium
|
||||||
|
from streamlit_folium import st_folium
|
||||||
|
|
||||||
|
m = folium.Map(location=[37.7749, -122.4194], zoom_start=12)
|
||||||
|
st_folium(m, width=700)
|
||||||
|
```
|
||||||
|
|
||||||
|
### pygwalker
|
||||||
|
|
||||||
|
Tableau-like drag-and-drop data exploration.
|
||||||
|
|
||||||
|
- **Repo:** https://github.com/Kanaries/pygwalker
|
||||||
|
- **Docs:** https://docs.kanaries.net/pygwalker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add pygwalker
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pygwalker as pyg
|
||||||
|
|
||||||
|
pyg.walk(df, env="Streamlit")
|
||||||
|
```
|
||||||
|
|
||||||
|
### streamlit-extras
|
||||||
|
|
||||||
|
A collection of community utilities. Cherry-pick what you need.
|
||||||
|
|
||||||
|
- **Repo:** https://github.com/arnaudmiribel/streamlit-extras
|
||||||
|
- **Docs:** https://extras.streamlit.app/
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add streamlit-extras
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from streamlit_extras.image_selector import image_selector
|
||||||
|
|
||||||
|
# Let users click on regions of an image
|
||||||
|
selection = image_selector(image, selections=["Region A", "Region B"])
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from streamlit_extras.vertical_slider import vertical_slider
|
||||||
|
|
||||||
|
# A vertical slider widget
|
||||||
|
value = vertical_slider("Volume", min_value=0, max_value=100, default_value=50)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discover more
|
||||||
|
|
||||||
|
Browse the custom component gallery: https://streamlit.io/components
|
||||||
|
|
||||||
|
Filter by category, popularity, and recency to find custom components for your use case.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Components Gallery](https://streamlit.io/components)
|
||||||
|
- [Build a custom component](https://docs.streamlit.io/develop/concepts/custom-components)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
---
|
||||||
|
name: using-streamlit-layouts
|
||||||
|
description: Structuring Streamlit app layouts. Use when placing content in sidebars, columns, containers, or dialogs. Covers sidebar usage, column limits, horizontal containers, dialogs, and bordered cards.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streamlit layout
|
||||||
|
|
||||||
|
How you structure your app affects usability more than you think.
|
||||||
|
|
||||||
|
## Sidebar: navigation + global filters only
|
||||||
|
|
||||||
|
The sidebar should only contain navigation and app-level filters. Main content goes in the main area.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GOOD
|
||||||
|
with st.sidebar:
|
||||||
|
date_range = st.date_input("Date range")
|
||||||
|
region = st.selectbox("Region", ["All", "US", "EU", "APAC"])
|
||||||
|
st.caption("App v1.2.3")
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Too much content in sidebar
|
||||||
|
with st.sidebar:
|
||||||
|
st.title("Dashboard")
|
||||||
|
st.dataframe(df) # Don't put main content here
|
||||||
|
st.bar_chart(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What goes in sidebar:**
|
||||||
|
- Global filters (date range, user selection, region)
|
||||||
|
- App info (version, feedback link)
|
||||||
|
|
||||||
|
**What stays out:**
|
||||||
|
- Main content, charts, tables, results
|
||||||
|
|
||||||
|
## Columns: max 4, set alignment
|
||||||
|
|
||||||
|
Don't use too many columns—they get cramped.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GOOD
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
# Custom widths (ratios)
|
||||||
|
col1, col2 = st.columns([2, 1]) # 2:1 ratio
|
||||||
|
|
||||||
|
# OK with alignment
|
||||||
|
cols = st.columns(4, vertical_alignment="center")
|
||||||
|
|
||||||
|
# BAD: Too many, cramped
|
||||||
|
col1, col2, col3, col4, col5, col6 = st.columns(6)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Horizontal containers for button groups
|
||||||
|
|
||||||
|
Use `st.container(horizontal=True)` instead of columns for button groups:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.container(horizontal=True):
|
||||||
|
st.button("Cancel")
|
||||||
|
st.button("Save")
|
||||||
|
st.button("Submit")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aligning elements
|
||||||
|
|
||||||
|
Use `horizontal_alignment` on containers to position elements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Center elements
|
||||||
|
with st.container(horizontal_alignment="center"):
|
||||||
|
st.image("logo.png", width=200)
|
||||||
|
st.title("Welcome")
|
||||||
|
|
||||||
|
# Right-align elements
|
||||||
|
with st.container(horizontal_alignment="right"):
|
||||||
|
st.button("Settings", icon=":material/settings:")
|
||||||
|
|
||||||
|
# Distribute evenly (great for button groups)
|
||||||
|
with st.container(horizontal=True, horizontal_alignment="distribute"):
|
||||||
|
st.button("Cancel")
|
||||||
|
st.button("Save")
|
||||||
|
st.button("Submit")
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `"left"` (default), `"center"`, `"right"`, `"distribute"`
|
||||||
|
|
||||||
|
## Bordered containers
|
||||||
|
|
||||||
|
Use `border=True` on containers for visual grouping. See `building-streamlit-dashboards` for dashboard-specific patterns like KPI cards.
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("Section title")
|
||||||
|
st.write("Grouped content here")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
Organize content into switchable views:
|
||||||
|
|
||||||
|
```python
|
||||||
|
tab1, tab2 = st.tabs(["Chart", "Data"])
|
||||||
|
|
||||||
|
with tab1:
|
||||||
|
st.line_chart(data)
|
||||||
|
with tab2:
|
||||||
|
st.dataframe(df)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expander
|
||||||
|
|
||||||
|
Collapsible sections for secondary content:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.expander("See details"):
|
||||||
|
st.write("Hidden content here")
|
||||||
|
st.code("print('hello')")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empty and placeholders
|
||||||
|
|
||||||
|
`st.empty()` creates a single-element placeholder that can be updated or cleared:
|
||||||
|
|
||||||
|
```python
|
||||||
|
placeholder = st.empty()
|
||||||
|
|
||||||
|
# Update the placeholder
|
||||||
|
placeholder.text("Loading...")
|
||||||
|
result = load_data()
|
||||||
|
placeholder.dataframe(result)
|
||||||
|
|
||||||
|
# Clear it
|
||||||
|
placeholder.empty()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Popover
|
||||||
|
|
||||||
|
Click to reveal content:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.popover("Settings"):
|
||||||
|
st.checkbox("Dark mode")
|
||||||
|
st.slider("Font size", 10, 24)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dialogs for focused interactions
|
||||||
|
|
||||||
|
Use `@st.dialog` for UI that doesn't need to be always visible:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@st.dialog("Confirm deletion")
|
||||||
|
def confirm_delete(item_name):
|
||||||
|
st.write(f"Are you sure you want to delete **{item_name}**?")
|
||||||
|
if st.button("Delete", type="primary"):
|
||||||
|
delete_item(item_name)
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
if st.button("Delete item"):
|
||||||
|
confirm_delete("My Document")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Dialogs rerun independently from the main script
|
||||||
|
- Use `st.session_state` to pass widget values from the dialog to the main app
|
||||||
|
- Call `st.rerun()` to close dialog and refresh main app
|
||||||
|
- Use `dismissible=False` for forced actions
|
||||||
|
- `st.sidebar` is not supported inside dialogs
|
||||||
|
|
||||||
|
**When to use dialogs:**
|
||||||
|
- Confirmation prompts
|
||||||
|
- Settings panels
|
||||||
|
- Forms that don't need to be always visible
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Control spacing between elements with `gap` on containers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Remove spacing for tight list-like UIs
|
||||||
|
with st.container(gap=None, border=True):
|
||||||
|
for item in items:
|
||||||
|
st.checkbox(item.text)
|
||||||
|
|
||||||
|
# Explicit gap sizes
|
||||||
|
with st.container(gap="small"):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Add vertical space with `st.space`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.space("small") # Small gap
|
||||||
|
st.space("medium") # Medium gap
|
||||||
|
st.space("large") # Large gap
|
||||||
|
st.space(50) # Custom pixels
|
||||||
|
```
|
||||||
|
|
||||||
|
## Width and height
|
||||||
|
|
||||||
|
Control element sizing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Stretch to fill available space (equal height columns)
|
||||||
|
cols = st.columns(2)
|
||||||
|
with cols[0].container(border=True, height="stretch"):
|
||||||
|
st.line_chart(data)
|
||||||
|
with cols[1].container(border=True, height="stretch"):
|
||||||
|
st.dataframe(df)
|
||||||
|
|
||||||
|
# Shrink to content size
|
||||||
|
st.container(width="content")
|
||||||
|
|
||||||
|
# Fixed pixel sizes
|
||||||
|
st.container(height=300)
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.columns](https://docs.streamlit.io/develop/api-reference/layout/st.columns)
|
||||||
|
- [st.container](https://docs.streamlit.io/develop/api-reference/layout/st.container)
|
||||||
|
- [st.sidebar](https://docs.streamlit.io/develop/api-reference/layout/st.sidebar)
|
||||||
|
- [st.tabs](https://docs.streamlit.io/develop/api-reference/layout/st.tabs)
|
||||||
|
- [st.expander](https://docs.streamlit.io/develop/api-reference/layout/st.expander)
|
||||||
|
- [st.popover](https://docs.streamlit.io/develop/api-reference/layout/st.popover)
|
||||||
|
- [st.empty](https://docs.streamlit.io/develop/api-reference/layout/st.empty)
|
||||||
|
- [st.dialog](https://docs.streamlit.io/develop/api-reference/execution-flow/st.dialog)
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
---
|
||||||
|
name: using-streamlit-markdown
|
||||||
|
description: Covers all Markdown features in Streamlit including GitHub-flavored syntax plus Streamlit extensions like colored text, badges, Material icons, and LaTeX. Use when formatting text, labels, tooltips, or any text-rendering element.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Markdown in Streamlit
|
||||||
|
|
||||||
|
Streamlit supports Markdown throughout its API—in `st.markdown()`, widget labels, help tooltips, metrics, `st.table()` cells, and more. Beyond standard GitHub-flavored Markdown, Streamlit adds colored text, badges, icons, and LaTeX.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Feature | Syntax | Example | Works in labels |
|
||||||
|
|---------|--------|---------|--------|
|
||||||
|
| Bold | `**text**` | `**Bold**` | ✓ |
|
||||||
|
| Italic | `*text*` | `*Italic*` | ✓ |
|
||||||
|
| Strikethrough | `~text~` | `~Strikethrough~` | ✓ |
|
||||||
|
| Inline code | `` `code` `` | `` `variable` `` | ✓ |
|
||||||
|
| Code block | ` ```lang...``` ` | ` ```python...``` ` | ✗ |
|
||||||
|
| Link | `[text](url)` | `[Streamlit](https://streamlit.io)` | ✓ |
|
||||||
|
| Image | `` | `` | ✓ |
|
||||||
|
| Heading | `# ` to `###### ` | `## Section` | ✗ |
|
||||||
|
| Blockquote | `> text` | `> Note` | ✗ |
|
||||||
|
| Horizontal rule | `---` | `---` | ✗ |
|
||||||
|
| Unordered list | `- item` | `- First`<br>`- Second` | ✗ |
|
||||||
|
| Ordered list | `1. item` | `1. First`<br>`2. Second` | ✗ |
|
||||||
|
| Task list | `- [ ]` / `- [x]` | `- [x] Done`<br>`- [ ] Todo` | ✗ |
|
||||||
|
| Table | `\| a \| b \|` | `\| H1 \| H2 \|`<br>`\|--\|--\|` | ✗ |
|
||||||
|
| Emoji | Direct or shortcode | `🎉` or `:tada:` | ✓ |
|
||||||
|
| Streamlit logo | `:streamlit:` | `:streamlit:` | ✓ |
|
||||||
|
| Material icon | `:material/icon_name:` | `:material/check_circle:` | ✓ |
|
||||||
|
| Colored text | `:color[text]` | `:red[Error]` | ✓ |
|
||||||
|
| Colored background | `:color-background[text]` | `:blue-background[Info]` | ✓ |
|
||||||
|
| Badge | `:color-badge[text]` | `:green-badge[Success]` | ✓ |
|
||||||
|
| Small text | `:small[text]` | `:small[footnote]` | ✓ |
|
||||||
|
| LaTeX (inline) | `$formula$` | `$ax^2 + bx + c$` | ✓ |
|
||||||
|
| LaTeX (block) | `$$formula$$` | `$$\int_0^1 x^2 dx$$` | ✗ |
|
||||||
|
|
||||||
|
## Where Markdown works
|
||||||
|
|
||||||
|
Markdown is supported in most places where text is rendered. Streamlit has three levels of markdown support:
|
||||||
|
|
||||||
|
**Full Markdown** — All syntax shown in the table above:
|
||||||
|
- `st.markdown()`, `st.write()`, `st.caption()`, `st.info()`, `st.warning()`, `st.error()`, `st.success()`, `st.table` cells and headers, tooltips (`help` parameter)
|
||||||
|
|
||||||
|
**Label subset** — Inline formatting only (see table above). Block elements (e.g. headings, lists, tables) are silently stripped:
|
||||||
|
- Widget and element labels (`st.button`, `st.checkbox`, `st.radio`, `st.expander`, `st.page_link`, etc.), `st.radio` and `st.select_slider` options, `st.tabs` names, `st.metric` label/value/delta, `st.title`, `st.header`, `st.subheader`, `st.image` caption, `st.dialog` title, `st.progress`, `st.spinner`.
|
||||||
|
|
||||||
|
**No Markdown** — Text displays literally:
|
||||||
|
- `st.text()`, `st.json()`, `st.dataframe()` / `st.data_editor()` cells, `st.selectbox` / `st.multiselect` options, input placeholders, `st.Page` titles, chart/map labels
|
||||||
|
|
||||||
|
## GitHub-flavored Markdown
|
||||||
|
|
||||||
|
Standard GFM syntax works as expected. Headings automatically get anchor links for navigation.
|
||||||
|
|
||||||
|
~~~python
|
||||||
|
st.markdown("""
|
||||||
|
# Heading
|
||||||
|
|
||||||
|
**Bold**, *italic*, ~~strikethrough~~, `inline code`, [links](url)
|
||||||
|
|
||||||
|
- Unordered list
|
||||||
|
- [x] Task list
|
||||||
|
|
||||||
|
| Column | Column |
|
||||||
|
|--------|--------|
|
||||||
|
| Cell | Cell |
|
||||||
|
|
||||||
|
> Blockquote
|
||||||
|
|
||||||
|
```python
|
||||||
|
code_block = "with syntax highlighting"
|
||||||
|
```
|
||||||
|
""")
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## Colored text, backgrounds, and badges
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.markdown(":red[Error] and :green[Success]") # Colored text
|
||||||
|
st.markdown(":blue-background[Highlighted]") # Colored background
|
||||||
|
st.markdown(":green-badge[Active] :red-badge[Inactive]") # Inline badges
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available colors:** `red`, `orange`, `yellow`, `green`, `blue`, `violet`, `gray`/`grey`, `rainbow`, `primary`
|
||||||
|
|
||||||
|
Note: `rainbow` is not supported for backgrounds or badges. Standalone badges also available via `st.badge()`.
|
||||||
|
|
||||||
|
## Material icons
|
||||||
|
|
||||||
|
Use Google Material Symbols with `:material/icon_name:` syntax. Find icons at [fonts.google.com/icons](https://fonts.google.com/icons)
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.markdown(":material/check_circle: Complete")
|
||||||
|
```
|
||||||
|
|
||||||
|
Material icons also work in `icon` parameters across many elements (`st.button`, `st.expander`, `st.info`, etc.).
|
||||||
|
|
||||||
|
## Emojis
|
||||||
|
|
||||||
|
Both Unicode emojis (preferred) and shortcodes work.
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.markdown("Hello! 👋 :+1: :tada: :streamlit:")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Material icons are preferred over emojis for a more professional look.
|
||||||
|
|
||||||
|
## LaTeX math
|
||||||
|
|
||||||
|
Single `$` for inline, double `$$` for display mode. Inline math requires non-whitespace after `$` to avoid conflicts with currency (e.g., "$5" won't be parsed as math).
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inline math
|
||||||
|
st.markdown("The quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$")
|
||||||
|
|
||||||
|
# Display math (centered, larger)
|
||||||
|
st.markdown("""
|
||||||
|
$$
|
||||||
|
\\sum_{i=1}^{n} x_i = x_1 + x_2 + ... + x_n
|
||||||
|
$$
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Images in Markdown
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.markdown("")
|
||||||
|
st.button(" Click me") # Image as icon in label
|
||||||
|
```
|
||||||
|
|
||||||
|
In labels, images display as icons with max height equal to font height.
|
||||||
|
|
||||||
|
## Markdown in element labels
|
||||||
|
|
||||||
|
Widgets, containers, and other elements support Markdown in their labels (using the label subset).
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.radio(":material/palette: Choose **color**", [":red-background[Red]", ":blue-background[Blue]", ":green-background[Green]"])
|
||||||
|
tab1, tab2 = st.tabs([":material/home: Home", ":material/settings: Settings"])
|
||||||
|
st.metric(label=":material/attach_money: Revenue", value=":green[$1.2M]", delta=":material/trending_up: 12%")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Escaping special characters
|
||||||
|
|
||||||
|
Use backslash to show literal characters: `\\[`, `\\*`, `1\\.`
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.markdown(":blue[Array: \\[1, 2, 3\\]]")
|
||||||
|
st.button("1\\. Not a list")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Markdown in st.table
|
||||||
|
|
||||||
|
`st.table()` renders Markdown in cells and headers.
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.table({
|
||||||
|
"**Name**": "Alice",
|
||||||
|
"**Status**": ":green-badge[Active]",
|
||||||
|
"**Role**": ":material/shield: Admin"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Combining features
|
||||||
|
|
||||||
|
Mix multiple features for rich formatting.
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.markdown("""
|
||||||
|
### :material/rocket: Launch status
|
||||||
|
|
||||||
|
| Phase | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| Build | :green-badge[Complete] | All tests passing |
|
||||||
|
| Deploy | :orange-badge[In Progress] | ETA: 2 hours |
|
||||||
|
| Monitor | :gray-badge[Pending] | Waiting on deploy |
|
||||||
|
|
||||||
|
:small[Last updated: just now]
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
## st.markdown - text alignment and width
|
||||||
|
|
||||||
|
Control layout with `text_alignment` and `width` parameters.
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.markdown("Centered heading", text_alignment="center") # left, center, right, justify
|
||||||
|
st.markdown("Content width only", width="content") # stretch, content, or pixels (e.g. 400)
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTML (use very sparingly!)
|
||||||
|
|
||||||
|
Mix Markdown with HTML using `unsafe_allow_html=True`. For pure HTML without markdown processing, use `st.html()` instead.
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.markdown("**Status:** <span style='color: coral'>Custom styled</span>", unsafe_allow_html=True)
|
||||||
|
st.html("<div class='custom'>Pure HTML content</div>")
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.markdown](https://docs.streamlit.io/develop/api-reference/text/st.markdown)
|
||||||
|
- [st.latex](https://docs.streamlit.io/develop/api-reference/text/st.latex)
|
||||||
|
- [GitHub-flavored Markdown spec](https://github.github.com/gfm)
|
||||||
|
- [Material Symbols](https://fonts.google.com/icons)
|
||||||
|
- [KaTeX supported functions](https://katex.org/docs/supported.html)
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
---
|
||||||
|
name: using-streamlit-session-state
|
||||||
|
description: Using st.session_state to manage state across Streamlit reruns. Use when persisting data, handling widget state, implementing callbacks, or debugging state issues. Covers initialization patterns, widget-state association, and common gotchas.
|
||||||
|
license: Apache-2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Streamlit session state
|
||||||
|
|
||||||
|
Streamlit reruns scripts top-to-bottom on every interaction. Without session state, variables reset each time. Use `st.session_state` to persist values across reruns.
|
||||||
|
|
||||||
|
## Basic usage
|
||||||
|
|
||||||
|
Session state is a dictionary-like object supporting attribute and bracket notation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Initialize with setdefault (preferred)
|
||||||
|
st.session_state.setdefault("count", 0)
|
||||||
|
|
||||||
|
# Alternative: check before setting
|
||||||
|
if "count" not in st.session_state:
|
||||||
|
st.session_state.count = 0
|
||||||
|
|
||||||
|
# Read
|
||||||
|
current = st.session_state.count
|
||||||
|
|
||||||
|
# Update
|
||||||
|
st.session_state.count += 1
|
||||||
|
st.session_state["count"] = 5 # Bracket notation also works
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
del st.session_state.count
|
||||||
|
```
|
||||||
|
|
||||||
|
**Accessing uninitialized keys raises `KeyError`.** Use `st.session_state.get("key", default)` for safe access.
|
||||||
|
|
||||||
|
## Widget-state association
|
||||||
|
|
||||||
|
Every widget with a `key` parameter automatically syncs to session state:
|
||||||
|
|
||||||
|
```python
|
||||||
|
name = st.text_input("Name", key="user_name")
|
||||||
|
# st.session_state.user_name contains the same value as `name`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Callbacks
|
||||||
|
|
||||||
|
Callbacks execute **before** the script reruns, allowing immediate state changes. Use `on_change` or `on_click` with optional `args` and `kwargs`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def increment(amount):
|
||||||
|
st.session_state.count += amount
|
||||||
|
|
||||||
|
st.button("Add 5", on_click=increment, args=(5,))
|
||||||
|
```
|
||||||
|
|
||||||
|
Access a widget's value in its own callback via `st.session_state.key`, not the return variable.
|
||||||
|
|
||||||
|
## Initialization patterns
|
||||||
|
|
||||||
|
Initialize all state at the top of your app for clarity:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.session_state.setdefault("user", None)
|
||||||
|
st.session_state.setdefault("page", "home")
|
||||||
|
st.session_state.setdefault("filters", {})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multipage state
|
||||||
|
|
||||||
|
Widgets are NOT stateful across pages. Their values reset when navigating between pages.
|
||||||
|
|
||||||
|
### Sharing state
|
||||||
|
|
||||||
|
Use session state variables (not widget keys) to share data:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Page 1: Store value
|
||||||
|
st.session_state.selected_user = st.selectbox("User", users)
|
||||||
|
|
||||||
|
# Page 2: Read stored value
|
||||||
|
if "selected_user" in st.session_state:
|
||||||
|
st.write(f"Selected: {st.session_state.selected_user}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared widgets pattern
|
||||||
|
|
||||||
|
Put common widgets in the entrypoint file (before `nav.run()`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app.py (entrypoint)
|
||||||
|
with st.sidebar:
|
||||||
|
st.session_state.theme = st.selectbox("Theme", ["Light", "Dark"])
|
||||||
|
|
||||||
|
nav = st.navigation(pages)
|
||||||
|
nav.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common mistakes
|
||||||
|
|
||||||
|
### Module-level mutable state
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: In imported modules, this is shared across ALL users
|
||||||
|
# utils.py
|
||||||
|
cache = {} # Persists across reruns AND users!
|
||||||
|
|
||||||
|
# GOOD: Use session state for per-user data
|
||||||
|
st.session_state.setdefault("cache", {})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifying state after widget creation
|
||||||
|
|
||||||
|
Cannot assign to a widget's state after the widget has rendered:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.slider("Value", key="my_slider")
|
||||||
|
st.session_state.my_slider = 50 # Raises StreamlitAPIException!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mixing `value` parameter and session state
|
||||||
|
|
||||||
|
Don't set both—it causes warnings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Conflicting sources
|
||||||
|
st.session_state.setdefault("name", "Alice")
|
||||||
|
st.text_input("Name", value="Bob", key="name") # Warning!
|
||||||
|
|
||||||
|
# GOOD: Use one or the other
|
||||||
|
st.session_state.setdefault("name", "Alice")
|
||||||
|
st.text_input("Name", key="name")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session characteristics
|
||||||
|
|
||||||
|
- **Per-user, per-tab**: Each browser tab has its own session
|
||||||
|
- **Temporary**: Lost when tab closes or server restarts
|
||||||
|
- **Not suitable for persistence**: Use databases for permanent storage
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [st.session_state API](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.session_state)
|
||||||
|
- [Session State concepts](https://docs.streamlit.io/develop/concepts/architecture/session-state)
|
||||||
|
- [Widget behavior](https://docs.streamlit.io/develop/concepts/architecture/widget-behavior)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Streamlit Dashboard App Templates
|
||||||
|
|
||||||
|
This directory contains ready-to-use dashboard templates for Streamlit. Each template demonstrates best practices for building data-driven dashboards with modern UI patterns.
|
||||||
|
|
||||||
|
## Available Templates
|
||||||
|
|
||||||
|
### Public Demo Templates
|
||||||
|
|
||||||
|
These templates are based on official Streamlit demo apps and work out of the box:
|
||||||
|
|
||||||
|
| Template | Description | Key Features |
|
||||||
|
|----------|-------------|--------------|
|
||||||
|
| **dashboard-seattle-weather** | Weather data exploration dashboard | `st.metric`, `st.pills`, `st.altair_chart`, year comparison |
|
||||||
|
| **dashboard-stock-peers** | Stock peer analysis and comparison | `st.multiselect`, normalized charts, peer average calculation |
|
||||||
|
| **dashboard-stock-peers-snowflake** | Same as above but using Snowflake | `st.connection("snowflake")`, synthetic stock data in SQL |
|
||||||
|
|
||||||
|
### Analytics Dashboard Templates
|
||||||
|
|
||||||
|
These templates demonstrate common dashboard patterns with synthetic data. Replace the data generation functions with your actual data sources:
|
||||||
|
|
||||||
|
| Template | Description | Key Features |
|
||||||
|
|----------|-------------|--------------|
|
||||||
|
| **dashboard-metrics** | Core metrics dashboard with KPIs | Chart/table toggle, `st.popover` filters, TIME_RANGES (1M/6M/1Y/QTD/YTD/All) |
|
||||||
|
| **dashboard-metrics-snowflake** | Same as above but using Snowflake | `st.connection("snowflake")`, SQL-based data generation |
|
||||||
|
| **dashboard-feature-usage** | API endpoint usage analytics | Segmented control, starter kits, normalization toggle, rolling averages |
|
||||||
|
| **dashboard-companies** | Company leaderboard with drill-down | Interactive dataframe, sparkline columns, growth scores |
|
||||||
|
| **dashboard-compute** | Resource consumption monitoring | `@st.fragment`, `st.popover` filters, TIME_RANGES, line/bar toggle |
|
||||||
|
| **dashboard-compute-snowflake** | Same as above but using Snowflake | `st.connection("snowflake")`, SQL-based data generation |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Run a Template Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to a template directory
|
||||||
|
cd templates/apps/dashboard-metrics
|
||||||
|
|
||||||
|
# Install dependencies with uv
|
||||||
|
uv pip install -e .
|
||||||
|
|
||||||
|
# Run the app
|
||||||
|
uv run streamlit run streamlit_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Structure
|
||||||
|
|
||||||
|
Each template follows this structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
dashboard-{name}/
|
||||||
|
├── streamlit_app.py # Main application code
|
||||||
|
└── pyproject.toml # Dependencies and metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
## Canonical Patterns
|
||||||
|
|
||||||
|
When creating new templates or adapting existing ones, follow these patterns for consistency.
|
||||||
|
|
||||||
|
### Page Configuration
|
||||||
|
|
||||||
|
Always set page config as the first Streamlit call, with `layout="wide"` and a Material icon:
|
||||||
|
|
||||||
|
```python
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="My Dashboard",
|
||||||
|
page_icon=":material/monitoring:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
Use these standard constant names:
|
||||||
|
|
||||||
|
```python
|
||||||
|
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
|
||||||
|
CHART_HEIGHT = 300 # Standard chart height in pixels
|
||||||
|
```
|
||||||
|
|
||||||
|
### Time Range Filtering
|
||||||
|
|
||||||
|
All dashboard templates that support time filtering use the same `filter_by_time_range` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
|
||||||
|
"""Filter dataframe by time range."""
|
||||||
|
if time_range == "All" or df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df[x_col] = pd.to_datetime(df[x_col])
|
||||||
|
max_date = df[x_col].max()
|
||||||
|
|
||||||
|
if time_range == "1M":
|
||||||
|
min_date = max_date - timedelta(days=30)
|
||||||
|
elif time_range == "6M":
|
||||||
|
min_date = max_date - timedelta(days=180)
|
||||||
|
elif time_range == "1Y":
|
||||||
|
min_date = max_date - timedelta(days=365)
|
||||||
|
elif time_range == "QTD":
|
||||||
|
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
|
||||||
|
elif time_range == "YTD":
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, 1, 1))
|
||||||
|
else:
|
||||||
|
return df
|
||||||
|
|
||||||
|
return df[df[x_col] >= min_date]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Popover Filters
|
||||||
|
|
||||||
|
Compact filter controls using `st.popover`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
line_options = st.pills("Lines", ["Daily", "7-day MA"], selection_mode="multi")
|
||||||
|
time_range = st.segmented_control("Time range", TIME_RANGES, default="All")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Header with Reset Button
|
||||||
|
|
||||||
|
```python
|
||||||
|
def render_page_header(title: str):
|
||||||
|
"""Render page header with title and reset button."""
|
||||||
|
with st.container(
|
||||||
|
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
|
||||||
|
):
|
||||||
|
st.markdown(title)
|
||||||
|
if st.button(":material/restart_alt: Reset", type="tertiary"):
|
||||||
|
st.session_state.clear()
|
||||||
|
st.rerun()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Independent Widget Updates with @st.fragment
|
||||||
|
|
||||||
|
```python
|
||||||
|
@st.fragment
|
||||||
|
def metric_card():
|
||||||
|
with st.container(border=True):
|
||||||
|
# This widget updates independently without full page rerun
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snowflake Column Normalization
|
||||||
|
|
||||||
|
Snowflake returns uppercase column names. Always normalize after queries:
|
||||||
|
|
||||||
|
```python
|
||||||
|
df = conn.query(query)
|
||||||
|
df.columns = df.columns.str.lower()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snowflake Connection Error Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
get_snowflake_connection()
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Failed to connect to Snowflake: {e}")
|
||||||
|
st.info(
|
||||||
|
"Make sure you have configured your Snowflake connection in "
|
||||||
|
"`.streamlit/secrets.toml` or via environment variables."
|
||||||
|
)
|
||||||
|
st.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Loading with Caching
|
||||||
|
|
||||||
|
```python
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def load_metric_data() -> pd.DataFrame:
|
||||||
|
"""Load metric data. Replace with your actual data source."""
|
||||||
|
# Replace this with:
|
||||||
|
# - Snowflake query via st.connection("snowflake")
|
||||||
|
# - API call
|
||||||
|
# - Database query
|
||||||
|
return generate_synthetic_data()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
All templates require Python >=3.11 and use:
|
||||||
|
- `snowflake-connector-python>=3.3.0` (required — `streamlit[snowflake]` silently skips this on Python 3.12+)
|
||||||
|
- `streamlit[snowflake]>=1.54.0`
|
||||||
|
- `altair>=5.5.0`
|
||||||
|
- `pandas>=2.2.3`
|
||||||
|
- `numpy>=1.26.0`
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-companies"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "A company analytics dashboard with leaderboard, filtering, and drill-down dialogs"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
+365
@@ -0,0 +1,365 @@
|
|||||||
|
"""
|
||||||
|
Company Analytics Dashboard Template
|
||||||
|
|
||||||
|
A company leaderboard dashboard demonstrating:
|
||||||
|
- Interactive dataframe with sparkline columns
|
||||||
|
- Segmented control for ranking (top spenders, gainers, shrinkers)
|
||||||
|
- Multi-select pills for account type filtering
|
||||||
|
- Time window filtering
|
||||||
|
- Growth score calculation
|
||||||
|
- Dialog popup for company details
|
||||||
|
|
||||||
|
This template uses synthetic data. Replace generate_company_data()
|
||||||
|
with your actual data source (e.g., Snowflake queries, CRM APIs, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="Company Analytics",
|
||||||
|
page_icon=":material/business:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Synthetic Data Generation (Replace with your data source)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
COMPANY_NAMES = [
|
||||||
|
"Acme Corp", "TechFlow Inc", "DataDriven Co", "CloudFirst Ltd",
|
||||||
|
"InnovateTech", "ScaleUp Systems", "PrimeData Inc", "FutureStack",
|
||||||
|
"ByteWise Corp", "StreamLine Co", "Quantum Labs", "NexGen Solutions",
|
||||||
|
"AlphaMetrics", "BetaAnalytics", "GammaInsights", "DeltaData",
|
||||||
|
"OmegaTech", "SigmaSoft", "ThetaCloud", "ZetaDigital",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCOUNT_TYPES = ["Enterprise", "Growth", "Startup", "Trial", "Internal"]
|
||||||
|
REGIONS = ["North America", "EMEA", "APAC", "LATAM"]
|
||||||
|
SEGMENTS = ["Technology", "Finance", "Healthcare", "Retail", "Manufacturing"]
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def generate_company_data(days: int = 90) -> pd.DataFrame:
|
||||||
|
"""Generate synthetic company usage data.
|
||||||
|
|
||||||
|
Replace this function with your actual data source.
|
||||||
|
"""
|
||||||
|
np.random.seed(42)
|
||||||
|
|
||||||
|
end_date = date.today() - timedelta(days=1)
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
dates = pd.date_range(start=start_date, end=end_date, freq="D")
|
||||||
|
|
||||||
|
records = []
|
||||||
|
|
||||||
|
for company in COMPANY_NAMES:
|
||||||
|
# Assign static attributes
|
||||||
|
account_type = np.random.choice(ACCOUNT_TYPES, p=[0.3, 0.25, 0.2, 0.15, 0.1])
|
||||||
|
region = np.random.choice(REGIONS)
|
||||||
|
segment = np.random.choice(SEGMENTS)
|
||||||
|
|
||||||
|
# Generate usage pattern
|
||||||
|
base_usage = np.random.randint(100, 10000)
|
||||||
|
growth = np.random.uniform(-0.005, 0.01) # Some companies shrink
|
||||||
|
|
||||||
|
for i, dt in enumerate(dates):
|
||||||
|
# Base trend
|
||||||
|
trend = base_usage * (1 + growth) ** i
|
||||||
|
|
||||||
|
# Weekly seasonality
|
||||||
|
if dt.dayofweek >= 5:
|
||||||
|
trend *= 0.3
|
||||||
|
|
||||||
|
# Random noise
|
||||||
|
daily_credits = max(0, trend * np.random.uniform(0.7, 1.3))
|
||||||
|
|
||||||
|
records.append({
|
||||||
|
"company_name": company,
|
||||||
|
"date": dt,
|
||||||
|
"daily_credits": daily_credits,
|
||||||
|
"account_type": account_type,
|
||||||
|
"region": region,
|
||||||
|
"segment": segment,
|
||||||
|
})
|
||||||
|
|
||||||
|
return pd.DataFrame(records)
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def load_company_data() -> pd.DataFrame:
|
||||||
|
"""Load all company data."""
|
||||||
|
return generate_company_data(days=90)
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_companies(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
days: int | None = None,
|
||||||
|
account_types: list[str] | None = None,
|
||||||
|
sort_by: str = "total_credits",
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Filter and aggregate company data."""
|
||||||
|
result = df.copy()
|
||||||
|
|
||||||
|
# Filter by time window
|
||||||
|
if days:
|
||||||
|
cutoff = pd.Timestamp.now() - pd.Timedelta(days=days)
|
||||||
|
result = result[result["date"] >= cutoff]
|
||||||
|
|
||||||
|
# Filter by account type
|
||||||
|
if account_types:
|
||||||
|
result = result[result["account_type"].isin(account_types)]
|
||||||
|
|
||||||
|
if result.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Aggregate to company level
|
||||||
|
agg = result.groupby("company_name").agg(
|
||||||
|
total_credits=("daily_credits", "sum"),
|
||||||
|
active_days=("date", "nunique"),
|
||||||
|
account_type=("account_type", "first"),
|
||||||
|
region=("region", "first"),
|
||||||
|
segment=("segment", "first"),
|
||||||
|
).reset_index()
|
||||||
|
|
||||||
|
# Calculate daily average
|
||||||
|
agg["daily_avg"] = agg["total_credits"] / agg["active_days"]
|
||||||
|
|
||||||
|
# Build sparkline data (list of daily values)
|
||||||
|
sparklines = (
|
||||||
|
result.groupby("company_name")
|
||||||
|
.apply(lambda x: x.sort_values("date")["daily_credits"].tolist())
|
||||||
|
.reset_index()
|
||||||
|
)
|
||||||
|
sparklines.columns = ["company_name", "usage_trend"]
|
||||||
|
agg = agg.merge(sparklines, on="company_name")
|
||||||
|
|
||||||
|
# Calculate growth score (second half vs first half)
|
||||||
|
def calc_growth(trend):
|
||||||
|
if not trend or len(trend) < 2:
|
||||||
|
return 0
|
||||||
|
mid = len(trend) // 2
|
||||||
|
first_half = sum(trend[:mid]) if mid > 0 else 0
|
||||||
|
second_half = sum(trend[mid:])
|
||||||
|
return second_half - first_half
|
||||||
|
|
||||||
|
agg["growth_score"] = agg["usage_trend"].apply(calc_growth)
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
if sort_by == "growth_asc":
|
||||||
|
agg = agg.sort_values("growth_score", ascending=True)
|
||||||
|
elif sort_by == "growth_desc":
|
||||||
|
agg = agg.sort_values("growth_score", ascending=False)
|
||||||
|
else:
|
||||||
|
agg = agg.sort_values("total_credits", ascending=False)
|
||||||
|
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
def render_company_dialog(company_name: str, company_row: pd.Series, df: pd.DataFrame):
|
||||||
|
"""Render company details inside a dialog."""
|
||||||
|
company_data = df[df["company_name"] == company_name].sort_values("date")
|
||||||
|
|
||||||
|
if company_data.empty:
|
||||||
|
st.warning("No data available for this company.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Company info badges - extract from list format back to single value
|
||||||
|
account_type = company_row["account_type"][0] if company_row["account_type"] else "Unknown"
|
||||||
|
region = company_row["region"][0] if company_row["region"] else "Unknown"
|
||||||
|
segment = company_row["segment"][0] if company_row["segment"] else "Unknown"
|
||||||
|
total_credits = company_row["total_credits"]
|
||||||
|
|
||||||
|
st.markdown(
|
||||||
|
f":blue-badge[{account_type}] "
|
||||||
|
f":violet-badge[{region}] "
|
||||||
|
f":orange-badge[{segment}] "
|
||||||
|
f":green-badge[{total_credits:,.0f} credits]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Summary metrics
|
||||||
|
col1, col2, col3 = st.columns(3)
|
||||||
|
with col1:
|
||||||
|
st.metric("Total Credits", f"{total_credits:,.0f}")
|
||||||
|
with col2:
|
||||||
|
st.metric("Active Days", f"{company_row['active_days']:,}")
|
||||||
|
with col3:
|
||||||
|
growth = company_row["growth_score"]
|
||||||
|
st.metric("Growth Score", f"{growth:+,.0f}")
|
||||||
|
|
||||||
|
# Charts
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown("**Daily usage**")
|
||||||
|
st.line_chart(company_data, x="date", y="daily_credits", height=250)
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown("**Cumulative usage**")
|
||||||
|
chart_data = company_data.copy()
|
||||||
|
chart_data["cumulative"] = chart_data["daily_credits"].cumsum()
|
||||||
|
st.area_chart(chart_data, x="date", y="cumulative", height=250)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Layout
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
all_data = load_company_data()
|
||||||
|
|
||||||
|
st.markdown("# :material/business: Company Analytics")
|
||||||
|
st.caption("Track company adoption - usage, growth trends, and account details.")
|
||||||
|
|
||||||
|
# Filters
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown("**Filters**")
|
||||||
|
|
||||||
|
# Company selection mode
|
||||||
|
sort_mode = st.segmented_control(
|
||||||
|
"Sort by",
|
||||||
|
options=[
|
||||||
|
"All companies",
|
||||||
|
":material/military_tech: Top spenders",
|
||||||
|
":material/trending_down: Top shrinkers",
|
||||||
|
":material/trending_up: Top gainers",
|
||||||
|
],
|
||||||
|
default="All companies",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time window
|
||||||
|
timeframe_options = {
|
||||||
|
"All time": None,
|
||||||
|
"Last 28 days": 28,
|
||||||
|
"Last 7 days": 7,
|
||||||
|
}
|
||||||
|
timeframe = st.segmented_control(
|
||||||
|
"Time window",
|
||||||
|
options=list(timeframe_options.keys()),
|
||||||
|
default="Last 28 days",
|
||||||
|
)
|
||||||
|
days_filter = timeframe_options.get(timeframe)
|
||||||
|
|
||||||
|
# Account types
|
||||||
|
account_types = st.pills(
|
||||||
|
"Account types",
|
||||||
|
options=ACCOUNT_TYPES,
|
||||||
|
default=["Enterprise", "Growth", "Startup"],
|
||||||
|
selection_mode="multi",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine sort order
|
||||||
|
if "Top shrinkers" in (sort_mode or ""):
|
||||||
|
sort_by = "growth_asc"
|
||||||
|
elif "Top gainers" in (sort_mode or ""):
|
||||||
|
sort_by = "growth_desc"
|
||||||
|
else:
|
||||||
|
sort_by = "total_credits"
|
||||||
|
|
||||||
|
# Get filtered data
|
||||||
|
leaderboard = aggregate_companies(
|
||||||
|
all_data,
|
||||||
|
days=days_filter,
|
||||||
|
account_types=account_types,
|
||||||
|
sort_by=sort_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
if leaderboard.empty:
|
||||||
|
st.warning("No company data found for the selected filters.")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_list(val):
|
||||||
|
"""Convert a single value to a list for MultiselectColumn display."""
|
||||||
|
return [val] if pd.notna(val) else []
|
||||||
|
|
||||||
|
|
||||||
|
# Convert columns to lists for MultiselectColumn display (shows nice colored chips)
|
||||||
|
for col in ["account_type", "region", "segment"]:
|
||||||
|
leaderboard[col] = leaderboard[col].apply(_to_list)
|
||||||
|
|
||||||
|
# Companies dataframe
|
||||||
|
with st.container(border=True):
|
||||||
|
timeframe_text = timeframe.lower() if timeframe != "All time" else "all time"
|
||||||
|
st.markdown(f"**Companies — {timeframe_text}**")
|
||||||
|
|
||||||
|
# Selection dataframe with cell-click support
|
||||||
|
selection = st.dataframe(
|
||||||
|
leaderboard,
|
||||||
|
column_config={
|
||||||
|
"company_name": st.column_config.TextColumn(
|
||||||
|
"Company (👋 click to view details)",
|
||||||
|
width="medium",
|
||||||
|
),
|
||||||
|
"account_type": st.column_config.MultiselectColumn(
|
||||||
|
"Type",
|
||||||
|
options=ACCOUNT_TYPES,
|
||||||
|
color="auto",
|
||||||
|
width="small",
|
||||||
|
),
|
||||||
|
"total_credits": st.column_config.NumberColumn(
|
||||||
|
"Credits",
|
||||||
|
format="%.0f",
|
||||||
|
),
|
||||||
|
"growth_score": st.column_config.NumberColumn(
|
||||||
|
"Growth",
|
||||||
|
format="%+.0f",
|
||||||
|
help="Credit change: second half vs first half of period",
|
||||||
|
),
|
||||||
|
"usage_trend": st.column_config.LineChartColumn(
|
||||||
|
"Trend",
|
||||||
|
width="medium",
|
||||||
|
),
|
||||||
|
"daily_avg": st.column_config.NumberColumn(
|
||||||
|
"Daily Avg",
|
||||||
|
format="%.1f",
|
||||||
|
),
|
||||||
|
"active_days": st.column_config.NumberColumn(
|
||||||
|
"Active Days",
|
||||||
|
format="%d",
|
||||||
|
),
|
||||||
|
"region": st.column_config.MultiselectColumn(
|
||||||
|
"Region",
|
||||||
|
options=REGIONS,
|
||||||
|
color="auto",
|
||||||
|
),
|
||||||
|
"segment": st.column_config.MultiselectColumn(
|
||||||
|
"Segment",
|
||||||
|
options=SEGMENTS,
|
||||||
|
color="auto",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
column_order=[
|
||||||
|
"company_name", "account_type", "total_credits", "growth_score",
|
||||||
|
"usage_trend", "daily_avg", "region", "segment",
|
||||||
|
],
|
||||||
|
hide_index=True,
|
||||||
|
on_select="rerun",
|
||||||
|
selection_mode="single-cell",
|
||||||
|
key="company_leaderboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Company drill-down via dialog when Company column cell is clicked
|
||||||
|
if selection.selection.cells:
|
||||||
|
cell = selection.selection.cells[0] # tuple: (row_index, column_name)
|
||||||
|
row_idx, col_name = cell
|
||||||
|
# Check if the clicked cell is in the company_name column
|
||||||
|
if col_name == "company_name":
|
||||||
|
selected_company = leaderboard.iloc[row_idx]["company_name"]
|
||||||
|
company_row = leaderboard.iloc[row_idx]
|
||||||
|
|
||||||
|
@st.dialog(f"{selected_company}", width="large")
|
||||||
|
def show_company_dialog():
|
||||||
|
render_company_dialog(
|
||||||
|
selected_company,
|
||||||
|
company_row=company_row,
|
||||||
|
df=all_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
show_company_dialog()
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-compute-snowflake"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Compute dashboard template with Snowflake connection"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
definition_version: 2
|
||||||
|
entities:
|
||||||
|
DASHBOARD_COMPUTE_SNOWFLAKE:
|
||||||
|
type: streamlit
|
||||||
|
identifier:
|
||||||
|
name: DASHBOARD_COMPUTE_SNOWFLAKE
|
||||||
|
database: <FROM_CONNECTION> # Use: snow connection list
|
||||||
|
schema: <FROM_CONNECTION>
|
||||||
|
query_warehouse: <FROM_CONNECTION>
|
||||||
|
runtime_name: SYSTEM$ST_CONTAINER_RUNTIME_PY3_11
|
||||||
|
# List available: SHOW EXTERNAL ACCESS INTEGRATIONS;
|
||||||
|
# Common: PYPI_ACCESS_INTEGRATION (verify exists)
|
||||||
|
external_access_integrations:
|
||||||
|
- <YOUR_PYPI_INTEGRATION>
|
||||||
|
main_file: streamlit_app.py
|
||||||
|
artifacts:
|
||||||
|
- streamlit_app.py
|
||||||
|
- pyproject.toml
|
||||||
+527
@@ -0,0 +1,527 @@
|
|||||||
|
"""
|
||||||
|
Compute/Resource Dashboard Template (Snowflake Edition)
|
||||||
|
|
||||||
|
A resource consumption dashboard demonstrating:
|
||||||
|
- Snowflake connection via st.connection("snowflake")
|
||||||
|
- Parameterized queries for safe data loading
|
||||||
|
- Multiple metric cards in a grid layout
|
||||||
|
- @st.fragment for independent widget updates
|
||||||
|
- Popover filters for each metric card
|
||||||
|
- Chart/table view toggle
|
||||||
|
- Time range filtering (1M, 6M, 1Y, QTD, YTD, All)
|
||||||
|
|
||||||
|
This template uses synthetic data generated in Snowflake. Replace the
|
||||||
|
synthetic queries with your actual table queries in production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import re
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="Compute Dashboard (Snowflake)",
|
||||||
|
page_icon=":material/bolt:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
|
||||||
|
ACCOUNT_TYPES = ["Paying", "Trial", "Internal"]
|
||||||
|
INSTANCE_TYPES = ["Standard", "High Memory", "High CPU", "GPU"]
|
||||||
|
REGIONS = ["us-west-2", "us-east-1", "eu-west-1", "ap-northeast-1"]
|
||||||
|
CHART_HEIGHT = 350
|
||||||
|
|
||||||
|
# Base values for synthetic data generation
|
||||||
|
BASE_VALUES = {
|
||||||
|
"account_type": {"Paying": 8000, "Trial": 2000, "Internal": 1000},
|
||||||
|
"instance_type": {"Standard": 5000, "High Memory": 3000, "High CPU": 2000, "GPU": 1500},
|
||||||
|
"region": {"us-west-2": 4000, "us-east-1": 3500, "eu-west-1": 2500, "ap-northeast-1": 1500},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Snowflake Connection and Data Loading
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_snowflake_connection():
|
||||||
|
"""Get Snowflake connection via st.connection.
|
||||||
|
|
||||||
|
Displays an error and stops the app if the connection fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return st.connection("snowflake")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Failed to connect to Snowflake: {e}")
|
||||||
|
st.info(
|
||||||
|
"Make sure you have configured your Snowflake connection in "
|
||||||
|
"`.streamlit/secrets.toml` or via environment variables."
|
||||||
|
)
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# IMPORTANT: Use parameterized queries in production
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# This demo uses synthetic data generated via SQL. In production, always use
|
||||||
|
# parameterized queries to prevent SQL injection:
|
||||||
|
#
|
||||||
|
# # GOOD: Parameterized query (safe)
|
||||||
|
# conn = st.connection("snowflake")
|
||||||
|
# df = conn.query(
|
||||||
|
# "SELECT * FROM metrics WHERE category = :category AND ds >= :start_date",
|
||||||
|
# params={"category": selected_category, "start_date": start_date}
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# # BAD: f-string interpolation (SQL injection risk)
|
||||||
|
# df = conn.query(f"SELECT * FROM metrics WHERE category = '{user_input}'")
|
||||||
|
#
|
||||||
|
# The synthetic data generation below uses f-strings only because the values
|
||||||
|
# are hardcoded constants, not user input. Never use f-strings with user input.
|
||||||
|
|
||||||
|
def _validate_sql_identifier(name: str) -> str:
|
||||||
|
"""Validate that a string is a safe SQL identifier (letters, digits, underscores).
|
||||||
|
|
||||||
|
Raises ValueError if the name contains unexpected characters. This prevents
|
||||||
|
SQL injection if the function is ever modified to accept dynamic input.
|
||||||
|
"""
|
||||||
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
||||||
|
raise ValueError(f"Invalid SQL identifier: {name!r}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def build_synthetic_query(category_col: str, categories: list[str], base_values: dict[str, int]) -> str:
|
||||||
|
"""Build SQL query for synthetic data.
|
||||||
|
|
||||||
|
WARNING: This function uses f-strings for demo purposes only.
|
||||||
|
The categories are hardcoded constants defined in this file, not user input.
|
||||||
|
In production, always use parameterized queries with conn.query(..., params={}).
|
||||||
|
"""
|
||||||
|
# Validate the column name used as a SQL identifier (appears unquoted in SQL)
|
||||||
|
_validate_sql_identifier(category_col)
|
||||||
|
|
||||||
|
# Category values appear as string literals in SQL VALUES clause.
|
||||||
|
# Escape single quotes to prevent SQL injection.
|
||||||
|
safe_categories = [cat.replace("'", "''") for cat in categories]
|
||||||
|
|
||||||
|
# Build VALUES clause for categories with their base values
|
||||||
|
values_rows = ", ".join(
|
||||||
|
f"('{cat}', {base_values.get(orig, 1000)})"
|
||||||
|
for cat, orig in zip(safe_categories, categories)
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
WITH categories AS (
|
||||||
|
SELECT column1 AS category, column2 AS base_val
|
||||||
|
FROM VALUES {values_rows}
|
||||||
|
),
|
||||||
|
date_series AS (
|
||||||
|
SELECT DATEADD(day, -seq4(), CURRENT_DATE() - 1) AS ds
|
||||||
|
FROM TABLE(GENERATOR(ROWCOUNT => 730))
|
||||||
|
),
|
||||||
|
base_data AS (
|
||||||
|
SELECT
|
||||||
|
ds,
|
||||||
|
category,
|
||||||
|
base_val * POWER(1.002, DATEDIFF(day, DATEADD(year, -2, CURRENT_DATE()), ds)) AS base_trend,
|
||||||
|
CASE WHEN DAYOFWEEK(ds) IN (0, 6) THEN 0.4 ELSE 1.0 END AS seasonality,
|
||||||
|
1 + (RANDOM() / 10000000000000000000.0 - 0.5) * 0.4 AS noise
|
||||||
|
FROM date_series
|
||||||
|
CROSS JOIN categories
|
||||||
|
WHERE ds >= DATEADD(year, -2, CURRENT_DATE())
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ds,
|
||||||
|
category AS {category_col},
|
||||||
|
GREATEST(0, ROUND(base_trend * seasonality * noise, 2)) AS daily_credits,
|
||||||
|
ROUND(AVG(GREATEST(0, base_trend * seasonality * noise)) OVER (
|
||||||
|
PARTITION BY category
|
||||||
|
ORDER BY ds ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
|
||||||
|
), 2) AS credits_7d_ma
|
||||||
|
FROM base_data
|
||||||
|
ORDER BY ds, {category_col}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600, show_spinner="Loading account type data...")
|
||||||
|
def load_account_type_data() -> pd.DataFrame:
|
||||||
|
"""Load credits by account type from Snowflake."""
|
||||||
|
conn = get_snowflake_connection()
|
||||||
|
query = build_synthetic_query("account_type", ACCOUNT_TYPES, BASE_VALUES["account_type"])
|
||||||
|
df = conn.query(query)
|
||||||
|
df.columns = df.columns.str.lower()
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600, show_spinner="Loading instance type data...")
|
||||||
|
def load_instance_type_data() -> pd.DataFrame:
|
||||||
|
"""Load credits by instance type from Snowflake."""
|
||||||
|
conn = get_snowflake_connection()
|
||||||
|
query = build_synthetic_query("instance_type", INSTANCE_TYPES, BASE_VALUES["instance_type"])
|
||||||
|
df = conn.query(query)
|
||||||
|
df.columns = df.columns.str.lower()
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600, show_spinner="Loading region data...")
|
||||||
|
def load_region_data() -> pd.DataFrame:
|
||||||
|
"""Load credits by region from Snowflake."""
|
||||||
|
conn = get_snowflake_connection()
|
||||||
|
query = build_synthetic_query("region", REGIONS, BASE_VALUES["region"])
|
||||||
|
df = conn.query(query)
|
||||||
|
df.columns = df.columns.str.lower()
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Chart Utilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
|
||||||
|
"""Filter dataframe by time range."""
|
||||||
|
if time_range == "All" or df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df[x_col] = pd.to_datetime(df[x_col])
|
||||||
|
max_date = df[x_col].max()
|
||||||
|
|
||||||
|
if time_range == "1M":
|
||||||
|
min_date = max_date - timedelta(days=30)
|
||||||
|
elif time_range == "6M":
|
||||||
|
min_date = max_date - timedelta(days=180)
|
||||||
|
elif time_range == "1Y":
|
||||||
|
min_date = max_date - timedelta(days=365)
|
||||||
|
elif time_range == "QTD":
|
||||||
|
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
|
||||||
|
elif time_range == "YTD":
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, 1, 1))
|
||||||
|
else:
|
||||||
|
return df
|
||||||
|
|
||||||
|
return df[df[x_col] >= min_date]
|
||||||
|
|
||||||
|
|
||||||
|
def create_line_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_col: str,
|
||||||
|
color_col: str,
|
||||||
|
height: int,
|
||||||
|
show_percent: bool = False,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Create a line chart."""
|
||||||
|
y_format = ".1%" if show_percent else ",.0f"
|
||||||
|
|
||||||
|
return (
|
||||||
|
alt.Chart(df)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y(f"{y_col}:Q", title="Credits", axis=alt.Axis(format=y_format)),
|
||||||
|
color=alt.Color(f"{color_col}:N", legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip(f"{color_col}:N", title=color_col.replace("_", " ").title()),
|
||||||
|
alt.Tooltip(f"{y_col}:Q", title="Credits", format=y_format),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
.interactive()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_bar_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_col: str,
|
||||||
|
color_col: str,
|
||||||
|
height: int,
|
||||||
|
show_percent: bool = False,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Create a stacked bar chart."""
|
||||||
|
y_format = ".1%" if show_percent else ",.0f"
|
||||||
|
|
||||||
|
return (
|
||||||
|
alt.Chart(df)
|
||||||
|
.mark_bar()
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y(
|
||||||
|
f"{y_col}:Q",
|
||||||
|
title="Credits",
|
||||||
|
stack="normalize" if show_percent else True,
|
||||||
|
axis=alt.Axis(format=y_format),
|
||||||
|
),
|
||||||
|
color=alt.Color(f"{color_col}:N", legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip(f"{color_col}:N"),
|
||||||
|
alt.Tooltip(f"{y_col}:Q", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Header Component
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_header(title: str):
|
||||||
|
"""Render page header with title and reset button."""
|
||||||
|
with st.container(
|
||||||
|
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
|
||||||
|
):
|
||||||
|
st.markdown(title)
|
||||||
|
if st.button(":material/restart_alt: Reset", type="tertiary"):
|
||||||
|
st.session_state.clear()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Metric Card Components (using @st.fragment)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@st.fragment
|
||||||
|
def account_type_metric():
|
||||||
|
"""Account type metric card with independent state."""
|
||||||
|
data = load_account_type_data()
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
|
||||||
|
st.markdown("**Credits by account type**")
|
||||||
|
|
||||||
|
view_mode = st.segmented_control(
|
||||||
|
"View",
|
||||||
|
options=[":material/show_chart:", ":material/table:"],
|
||||||
|
default=":material/show_chart:",
|
||||||
|
key="acct_view",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
selected_types = st.pills(
|
||||||
|
"Account types",
|
||||||
|
options=ACCOUNT_TYPES,
|
||||||
|
default=["Paying"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key="acct_types",
|
||||||
|
)
|
||||||
|
line_options = st.pills(
|
||||||
|
"Lines",
|
||||||
|
options=["Daily", "7-day MA"],
|
||||||
|
default=["7-day MA"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key="acct_lines",
|
||||||
|
)
|
||||||
|
chart_type = st.segmented_control(
|
||||||
|
"Chart type",
|
||||||
|
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
|
||||||
|
default=":material/show_chart: Line",
|
||||||
|
key="acct_chart",
|
||||||
|
)
|
||||||
|
show_percent = st.toggle(
|
||||||
|
"Show %", value=False, key="acct_pct",
|
||||||
|
disabled="Line" in (chart_type or ""),
|
||||||
|
)
|
||||||
|
time_range = st.segmented_control(
|
||||||
|
"Time range",
|
||||||
|
options=TIME_RANGES,
|
||||||
|
default="All",
|
||||||
|
key="acct_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter data
|
||||||
|
selected_types = selected_types or ["Paying"]
|
||||||
|
line_options = line_options or ["7-day MA"]
|
||||||
|
filtered = data[data["account_type"].isin(selected_types)]
|
||||||
|
filtered = filter_by_time_range(filtered, "ds", time_range)
|
||||||
|
|
||||||
|
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
|
||||||
|
|
||||||
|
if "table" in (view_mode or ""):
|
||||||
|
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
|
||||||
|
else:
|
||||||
|
if "Bar" in (chart_type or ""):
|
||||||
|
st.altair_chart(
|
||||||
|
create_bar_chart(filtered, "ds", y_col, "account_type", CHART_HEIGHT, show_percent),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.altair_chart(
|
||||||
|
create_line_chart(filtered, "ds", y_col, "account_type", CHART_HEIGHT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.fragment
|
||||||
|
def instance_type_metric():
|
||||||
|
"""Instance type metric card with independent state."""
|
||||||
|
data = load_instance_type_data()
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
|
||||||
|
st.markdown("**Credits by instance type**")
|
||||||
|
|
||||||
|
view_mode = st.segmented_control(
|
||||||
|
"View",
|
||||||
|
options=[":material/show_chart:", ":material/table:"],
|
||||||
|
default=":material/show_chart:",
|
||||||
|
key="inst_view",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
selected_types = st.pills(
|
||||||
|
"Instance types",
|
||||||
|
options=INSTANCE_TYPES,
|
||||||
|
default=INSTANCE_TYPES,
|
||||||
|
selection_mode="multi",
|
||||||
|
key="inst_types",
|
||||||
|
)
|
||||||
|
line_options = st.pills(
|
||||||
|
"Lines",
|
||||||
|
options=["Daily", "7-day MA"],
|
||||||
|
default=["7-day MA"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key="inst_lines",
|
||||||
|
)
|
||||||
|
chart_type = st.segmented_control(
|
||||||
|
"Chart type",
|
||||||
|
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
|
||||||
|
default=":material/show_chart: Line",
|
||||||
|
key="inst_chart",
|
||||||
|
)
|
||||||
|
show_percent = st.toggle(
|
||||||
|
"Show %", value=False, key="inst_pct",
|
||||||
|
disabled="Line" in (chart_type or ""),
|
||||||
|
)
|
||||||
|
time_range = st.segmented_control(
|
||||||
|
"Time range",
|
||||||
|
options=TIME_RANGES,
|
||||||
|
default="All",
|
||||||
|
key="inst_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter data
|
||||||
|
selected_types = selected_types or INSTANCE_TYPES
|
||||||
|
line_options = line_options or ["7-day MA"]
|
||||||
|
filtered = data[data["instance_type"].isin(selected_types)]
|
||||||
|
filtered = filter_by_time_range(filtered, "ds", time_range)
|
||||||
|
|
||||||
|
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
|
||||||
|
|
||||||
|
if "table" in (view_mode or ""):
|
||||||
|
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
|
||||||
|
else:
|
||||||
|
if "Bar" in (chart_type or ""):
|
||||||
|
st.altair_chart(
|
||||||
|
create_bar_chart(filtered, "ds", y_col, "instance_type", CHART_HEIGHT, show_percent),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.altair_chart(
|
||||||
|
create_line_chart(filtered, "ds", y_col, "instance_type", CHART_HEIGHT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.fragment
|
||||||
|
def region_metric():
|
||||||
|
"""Region metric card with independent state."""
|
||||||
|
data = load_region_data()
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
|
||||||
|
st.markdown("**Credits by region**")
|
||||||
|
|
||||||
|
view_mode = st.segmented_control(
|
||||||
|
"View",
|
||||||
|
options=[":material/show_chart:", ":material/table:"],
|
||||||
|
default=":material/show_chart:",
|
||||||
|
key="region_view",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
selected_regions = st.pills(
|
||||||
|
"Regions",
|
||||||
|
options=REGIONS,
|
||||||
|
default=REGIONS,
|
||||||
|
selection_mode="multi",
|
||||||
|
key="region_select",
|
||||||
|
)
|
||||||
|
line_options = st.pills(
|
||||||
|
"Lines",
|
||||||
|
options=["Daily", "7-day MA"],
|
||||||
|
default=["7-day MA"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key="region_lines",
|
||||||
|
)
|
||||||
|
chart_type = st.segmented_control(
|
||||||
|
"Chart type",
|
||||||
|
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
|
||||||
|
default=":material/bar_chart: Bar",
|
||||||
|
key="region_chart",
|
||||||
|
)
|
||||||
|
show_percent = st.toggle(
|
||||||
|
"Show %", value=False, key="region_pct",
|
||||||
|
disabled="Line" in (chart_type or ""),
|
||||||
|
)
|
||||||
|
time_range = st.segmented_control(
|
||||||
|
"Time range",
|
||||||
|
options=TIME_RANGES,
|
||||||
|
default="All",
|
||||||
|
key="region_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter data
|
||||||
|
selected_regions = selected_regions or REGIONS
|
||||||
|
line_options = line_options or ["7-day MA"]
|
||||||
|
filtered = data[data["region"].isin(selected_regions)]
|
||||||
|
filtered = filter_by_time_range(filtered, "ds", time_range)
|
||||||
|
|
||||||
|
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
|
||||||
|
|
||||||
|
if "table" in (view_mode or ""):
|
||||||
|
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
|
||||||
|
else:
|
||||||
|
if "Bar" in (chart_type or ""):
|
||||||
|
st.altair_chart(
|
||||||
|
create_bar_chart(filtered, "ds", y_col, "region", CHART_HEIGHT, show_percent),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.altair_chart(
|
||||||
|
create_line_chart(filtered, "ds", y_col, "region", CHART_HEIGHT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Layout
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Check Snowflake connection
|
||||||
|
get_snowflake_connection()
|
||||||
|
|
||||||
|
render_page_header("# :material/bolt: Compute Dashboard")
|
||||||
|
st.caption(":material/cloud: Powered by Snowflake")
|
||||||
|
|
||||||
|
# Row 1: Two metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
account_type_metric()
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
instance_type_metric()
|
||||||
|
|
||||||
|
# Row 2: One metric (full width for region breakdown)
|
||||||
|
region_metric()
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-compute"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "A compute/resource consumption dashboard with multiple metric breakdowns"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
+461
@@ -0,0 +1,461 @@
|
|||||||
|
"""
|
||||||
|
Compute/Resource Dashboard Template
|
||||||
|
|
||||||
|
A resource consumption dashboard demonstrating:
|
||||||
|
- Multiple metric cards in a grid layout
|
||||||
|
- @st.fragment for independent widget updates
|
||||||
|
- Popover filters for each metric card
|
||||||
|
- Chart/table view toggle
|
||||||
|
- Time range filtering (1M, 6M, 1Y, QTD, YTD, All)
|
||||||
|
- Percentage normalization toggle
|
||||||
|
- Multiple breakdown dimensions
|
||||||
|
|
||||||
|
This template uses synthetic data. Replace generate_*_data()
|
||||||
|
with your actual data source (e.g., Snowflake queries, cloud APIs, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="Compute Dashboard",
|
||||||
|
page_icon=":material/bolt:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
|
||||||
|
ACCOUNT_TYPES = ["Paying", "Trial", "Internal"]
|
||||||
|
INSTANCE_TYPES = ["Standard", "High Memory", "High CPU", "GPU"]
|
||||||
|
REGIONS = ["us-west-2", "us-east-1", "eu-west-1", "ap-northeast-1"]
|
||||||
|
CHART_HEIGHT = 350
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Synthetic Data Generation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def generate_time_series(
|
||||||
|
categories: list[str],
|
||||||
|
category_name: str,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
base_values: dict[str, float] | None = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Generate synthetic time series data by category."""
|
||||||
|
np.random.seed(hash(category_name) % 2**32)
|
||||||
|
|
||||||
|
dates = pd.date_range(start=start_date, end=end_date, freq="D")
|
||||||
|
records = []
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
base = base_values.get(category, 1000) if base_values else np.random.randint(500, 5000)
|
||||||
|
growth = np.random.uniform(0.001, 0.005)
|
||||||
|
|
||||||
|
for i, dt in enumerate(dates):
|
||||||
|
trend = base * (1 + growth) ** i
|
||||||
|
if dt.dayofweek >= 5:
|
||||||
|
trend *= 0.4
|
||||||
|
|
||||||
|
daily = max(0, trend * np.random.uniform(0.8, 1.2))
|
||||||
|
|
||||||
|
records.append({
|
||||||
|
"ds": dt,
|
||||||
|
category_name: category,
|
||||||
|
"daily_credits": daily,
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
|
||||||
|
# Add 7-day moving average
|
||||||
|
df["credits_7d_ma"] = (
|
||||||
|
df.groupby(category_name)["daily_credits"]
|
||||||
|
.transform(lambda x: x.rolling(7, min_periods=1).mean())
|
||||||
|
)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def load_account_type_data() -> pd.DataFrame:
|
||||||
|
"""Load credits by account type."""
|
||||||
|
end_date = date.today() - timedelta(days=1)
|
||||||
|
start_date = end_date - timedelta(days=730) # 2 years
|
||||||
|
return generate_time_series(
|
||||||
|
ACCOUNT_TYPES, "account_type", start_date, end_date,
|
||||||
|
base_values={"Paying": 8000, "Trial": 2000, "Internal": 1000},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def load_instance_type_data() -> pd.DataFrame:
|
||||||
|
"""Load credits by instance type."""
|
||||||
|
end_date = date.today() - timedelta(days=1)
|
||||||
|
start_date = end_date - timedelta(days=730)
|
||||||
|
return generate_time_series(
|
||||||
|
INSTANCE_TYPES, "instance_type", start_date, end_date,
|
||||||
|
base_values={"Standard": 5000, "High Memory": 3000, "High CPU": 2000, "GPU": 1500},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def load_region_data() -> pd.DataFrame:
|
||||||
|
"""Load credits by region."""
|
||||||
|
end_date = date.today() - timedelta(days=1)
|
||||||
|
start_date = end_date - timedelta(days=730)
|
||||||
|
return generate_time_series(
|
||||||
|
REGIONS, "region", start_date, end_date,
|
||||||
|
base_values={"us-west-2": 4000, "us-east-1": 3500, "eu-west-1": 2500, "ap-northeast-1": 1500},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Chart Utilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
|
||||||
|
"""Filter dataframe by time range."""
|
||||||
|
if time_range == "All" or df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df[x_col] = pd.to_datetime(df[x_col])
|
||||||
|
max_date = df[x_col].max()
|
||||||
|
|
||||||
|
if time_range == "1M":
|
||||||
|
min_date = max_date - timedelta(days=30)
|
||||||
|
elif time_range == "6M":
|
||||||
|
min_date = max_date - timedelta(days=180)
|
||||||
|
elif time_range == "1Y":
|
||||||
|
min_date = max_date - timedelta(days=365)
|
||||||
|
elif time_range == "QTD":
|
||||||
|
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
|
||||||
|
elif time_range == "YTD":
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, 1, 1))
|
||||||
|
else:
|
||||||
|
return df
|
||||||
|
|
||||||
|
return df[df[x_col] >= min_date]
|
||||||
|
|
||||||
|
|
||||||
|
def create_line_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_col: str,
|
||||||
|
color_col: str,
|
||||||
|
height: int,
|
||||||
|
show_percent: bool = False,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Create a line chart."""
|
||||||
|
y_format = ".1%" if show_percent else ",.0f"
|
||||||
|
|
||||||
|
return (
|
||||||
|
alt.Chart(df)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y(f"{y_col}:Q", title="Credits", axis=alt.Axis(format=y_format)),
|
||||||
|
color=alt.Color(f"{color_col}:N", legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip(f"{color_col}:N", title=color_col.replace("_", " ").title()),
|
||||||
|
alt.Tooltip(f"{y_col}:Q", title="Credits", format=y_format),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
.interactive()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_bar_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_col: str,
|
||||||
|
color_col: str,
|
||||||
|
height: int,
|
||||||
|
show_percent: bool = False,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Create a stacked bar chart."""
|
||||||
|
y_format = ".1%" if show_percent else ",.0f"
|
||||||
|
|
||||||
|
return (
|
||||||
|
alt.Chart(df)
|
||||||
|
.mark_bar()
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y(
|
||||||
|
f"{y_col}:Q",
|
||||||
|
title="Credits",
|
||||||
|
stack="normalize" if show_percent else True,
|
||||||
|
axis=alt.Axis(format=y_format),
|
||||||
|
),
|
||||||
|
color=alt.Color(f"{color_col}:N", legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip(f"{color_col}:N"),
|
||||||
|
alt.Tooltip(f"{y_col}:Q", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Header Component
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_header(title: str):
|
||||||
|
"""Render page header with title and reset button."""
|
||||||
|
with st.container(
|
||||||
|
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
|
||||||
|
):
|
||||||
|
st.markdown(title)
|
||||||
|
if st.button(":material/restart_alt: Reset", type="tertiary"):
|
||||||
|
st.session_state.clear()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Metric Card Components (using @st.fragment)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@st.fragment
|
||||||
|
def account_type_metric():
|
||||||
|
"""Account type metric card with independent state."""
|
||||||
|
data = load_account_type_data()
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
|
||||||
|
st.markdown("**Credits by account type**")
|
||||||
|
|
||||||
|
view_mode = st.segmented_control(
|
||||||
|
"View",
|
||||||
|
options=[":material/show_chart:", ":material/table:"],
|
||||||
|
default=":material/show_chart:",
|
||||||
|
key="acct_view",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
selected_types = st.pills(
|
||||||
|
"Account types",
|
||||||
|
options=ACCOUNT_TYPES,
|
||||||
|
default=["Paying"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key="acct_types",
|
||||||
|
)
|
||||||
|
line_options = st.pills(
|
||||||
|
"Lines",
|
||||||
|
options=["Daily", "7-day MA"],
|
||||||
|
default=["7-day MA"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key="acct_lines",
|
||||||
|
)
|
||||||
|
chart_type = st.segmented_control(
|
||||||
|
"Chart type",
|
||||||
|
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
|
||||||
|
default=":material/show_chart: Line",
|
||||||
|
key="acct_chart",
|
||||||
|
)
|
||||||
|
show_percent = st.toggle(
|
||||||
|
"Show %", value=False, key="acct_pct",
|
||||||
|
disabled="Line" in (chart_type or ""),
|
||||||
|
)
|
||||||
|
time_range = st.segmented_control(
|
||||||
|
"Time range",
|
||||||
|
options=TIME_RANGES,
|
||||||
|
default="All",
|
||||||
|
key="acct_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter data
|
||||||
|
selected_types = selected_types or ["Paying"]
|
||||||
|
line_options = line_options or ["7-day MA"]
|
||||||
|
filtered = data[data["account_type"].isin(selected_types)]
|
||||||
|
filtered = filter_by_time_range(filtered, "ds", time_range)
|
||||||
|
|
||||||
|
# Determine y column
|
||||||
|
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
|
||||||
|
|
||||||
|
if "table" in (view_mode or ""):
|
||||||
|
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
|
||||||
|
else:
|
||||||
|
if "Bar" in (chart_type or ""):
|
||||||
|
st.altair_chart(
|
||||||
|
create_bar_chart(filtered, "ds", y_col, "account_type", CHART_HEIGHT, show_percent),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.altair_chart(
|
||||||
|
create_line_chart(filtered, "ds", y_col, "account_type", CHART_HEIGHT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.fragment
|
||||||
|
def instance_type_metric():
|
||||||
|
"""Instance type metric card with independent state."""
|
||||||
|
data = load_instance_type_data()
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
|
||||||
|
st.markdown("**Credits by instance type**")
|
||||||
|
|
||||||
|
view_mode = st.segmented_control(
|
||||||
|
"View",
|
||||||
|
options=[":material/show_chart:", ":material/table:"],
|
||||||
|
default=":material/show_chart:",
|
||||||
|
key="inst_view",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
selected_types = st.pills(
|
||||||
|
"Instance types",
|
||||||
|
options=INSTANCE_TYPES,
|
||||||
|
default=INSTANCE_TYPES,
|
||||||
|
selection_mode="multi",
|
||||||
|
key="inst_types",
|
||||||
|
)
|
||||||
|
line_options = st.pills(
|
||||||
|
"Lines",
|
||||||
|
options=["Daily", "7-day MA"],
|
||||||
|
default=["7-day MA"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key="inst_lines",
|
||||||
|
)
|
||||||
|
chart_type = st.segmented_control(
|
||||||
|
"Chart type",
|
||||||
|
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
|
||||||
|
default=":material/show_chart: Line",
|
||||||
|
key="inst_chart",
|
||||||
|
)
|
||||||
|
show_percent = st.toggle(
|
||||||
|
"Show %", value=False, key="inst_pct",
|
||||||
|
disabled="Line" in (chart_type or ""),
|
||||||
|
)
|
||||||
|
time_range = st.segmented_control(
|
||||||
|
"Time range",
|
||||||
|
options=TIME_RANGES,
|
||||||
|
default="All",
|
||||||
|
key="inst_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter data
|
||||||
|
selected_types = selected_types or INSTANCE_TYPES
|
||||||
|
line_options = line_options or ["7-day MA"]
|
||||||
|
filtered = data[data["instance_type"].isin(selected_types)]
|
||||||
|
filtered = filter_by_time_range(filtered, "ds", time_range)
|
||||||
|
|
||||||
|
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
|
||||||
|
|
||||||
|
if "table" in (view_mode or ""):
|
||||||
|
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
|
||||||
|
else:
|
||||||
|
if "Bar" in (chart_type or ""):
|
||||||
|
st.altair_chart(
|
||||||
|
create_bar_chart(filtered, "ds", y_col, "instance_type", CHART_HEIGHT, show_percent),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.altair_chart(
|
||||||
|
create_line_chart(filtered, "ds", y_col, "instance_type", CHART_HEIGHT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.fragment
|
||||||
|
def region_metric():
|
||||||
|
"""Region metric card with independent state."""
|
||||||
|
data = load_region_data()
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
|
||||||
|
st.markdown("**Credits by region**")
|
||||||
|
|
||||||
|
view_mode = st.segmented_control(
|
||||||
|
"View",
|
||||||
|
options=[":material/show_chart:", ":material/table:"],
|
||||||
|
default=":material/show_chart:",
|
||||||
|
key="region_view",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
selected_regions = st.pills(
|
||||||
|
"Regions",
|
||||||
|
options=REGIONS,
|
||||||
|
default=REGIONS,
|
||||||
|
selection_mode="multi",
|
||||||
|
key="region_select",
|
||||||
|
)
|
||||||
|
line_options = st.pills(
|
||||||
|
"Lines",
|
||||||
|
options=["Daily", "7-day MA"],
|
||||||
|
default=["7-day MA"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key="region_lines",
|
||||||
|
)
|
||||||
|
chart_type = st.segmented_control(
|
||||||
|
"Chart type",
|
||||||
|
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
|
||||||
|
default=":material/bar_chart: Bar",
|
||||||
|
key="region_chart",
|
||||||
|
)
|
||||||
|
show_percent = st.toggle(
|
||||||
|
"Show %", value=False, key="region_pct",
|
||||||
|
disabled="Line" in (chart_type or ""),
|
||||||
|
)
|
||||||
|
time_range = st.segmented_control(
|
||||||
|
"Time range",
|
||||||
|
options=TIME_RANGES,
|
||||||
|
default="All",
|
||||||
|
key="region_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter data
|
||||||
|
selected_regions = selected_regions or REGIONS
|
||||||
|
line_options = line_options or ["7-day MA"]
|
||||||
|
filtered = data[data["region"].isin(selected_regions)]
|
||||||
|
filtered = filter_by_time_range(filtered, "ds", time_range)
|
||||||
|
|
||||||
|
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
|
||||||
|
|
||||||
|
if "table" in (view_mode or ""):
|
||||||
|
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
|
||||||
|
else:
|
||||||
|
if "Bar" in (chart_type or ""):
|
||||||
|
st.altair_chart(
|
||||||
|
create_bar_chart(filtered, "ds", y_col, "region", CHART_HEIGHT, show_percent),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.altair_chart(
|
||||||
|
create_line_chart(filtered, "ds", y_col, "region", CHART_HEIGHT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Layout
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
render_page_header("# :material/bolt: Compute Dashboard")
|
||||||
|
|
||||||
|
# Row 1: Two metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
account_type_metric()
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
instance_type_metric()
|
||||||
|
|
||||||
|
# Row 2: One metric (full width for region breakdown)
|
||||||
|
region_metric()
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-feature-usage"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "A feature usage analytics dashboard with filtering and starter kits"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
API Usage Dashboard Template
|
||||||
|
|
||||||
|
A feature analytics dashboard demonstrating:
|
||||||
|
- Segmented control for category selection
|
||||||
|
- Multiselect for endpoint filtering
|
||||||
|
- Starter kits / presets for quick selection
|
||||||
|
- Time series visualization with normalization
|
||||||
|
- Metric cards with 28-day deltas
|
||||||
|
- Rolling average options
|
||||||
|
|
||||||
|
This template uses synthetic data. Replace generate_api_data()
|
||||||
|
with your actual data source (e.g., Snowflake queries, APIs, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="API Usage Dashboard",
|
||||||
|
page_icon=":material/api:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Synthetic Data Generation (Replace with your data source)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# API categories and their endpoints
|
||||||
|
API_CATEGORIES = {
|
||||||
|
"Users": ["/users", "/users/{id}", "/users/me", "/users/search", "/users/bulk", "/users/export"],
|
||||||
|
"Orders": ["/orders", "/orders/{id}", "/orders/create", "/orders/cancel", "/orders/refund", "/orders/status"],
|
||||||
|
"Products": ["/products", "/products/{id}", "/products/search", "/products/categories", "/products/inventory"],
|
||||||
|
"Analytics": ["/analytics/events", "/analytics/metrics", "/analytics/reports", "/analytics/dashboards"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Starter kits - predefined endpoint selections
|
||||||
|
STARTER_KITS = {
|
||||||
|
"None": [],
|
||||||
|
"Core CRUD": ["/users", "/users/{id}", "/orders", "/orders/{id}"],
|
||||||
|
"Search": ["/users/search", "/products/search", "/products/categories"],
|
||||||
|
"Analytics": ["/analytics/events", "/analytics/metrics", "/analytics/reports"],
|
||||||
|
"High Volume": ["/users", "/products", "/orders", "/analytics/events"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLLING_OPTIONS = {"Raw": 1, "7-day average": 7, "28-day average": 28}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_data(
|
||||||
|
endpoints: list[str],
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Generate synthetic API usage data.
|
||||||
|
|
||||||
|
Replace this function with your actual data source.
|
||||||
|
"""
|
||||||
|
np.random.seed(42)
|
||||||
|
|
||||||
|
dates = pd.date_range(start=start_date, end=end_date, freq="D")
|
||||||
|
records = []
|
||||||
|
|
||||||
|
for endpoint in endpoints:
|
||||||
|
# Each endpoint has different base traffic and growth
|
||||||
|
base = np.random.randint(1000, 50000)
|
||||||
|
growth = np.random.uniform(0.0005, 0.003)
|
||||||
|
|
||||||
|
for i, dt in enumerate(dates):
|
||||||
|
# Base trend with growth
|
||||||
|
trend = base * (1 + growth) ** i
|
||||||
|
|
||||||
|
# Weekly seasonality (lower on weekends)
|
||||||
|
if dt.dayofweek >= 5:
|
||||||
|
trend *= 0.4
|
||||||
|
|
||||||
|
# Random noise
|
||||||
|
value = trend * np.random.uniform(0.85, 1.15)
|
||||||
|
|
||||||
|
records.append({
|
||||||
|
"date": dt,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"request_count": int(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def load_api_data() -> pd.DataFrame:
|
||||||
|
"""Load all API usage data."""
|
||||||
|
end_date = date.today() - timedelta(days=1)
|
||||||
|
start_date = end_date - timedelta(days=365)
|
||||||
|
|
||||||
|
all_endpoints = []
|
||||||
|
for endpoints in API_CATEGORIES.values():
|
||||||
|
all_endpoints.extend(endpoints)
|
||||||
|
|
||||||
|
return generate_api_data(all_endpoints, start_date, end_date)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_rolling_average(df: pd.DataFrame, window: int) -> pd.DataFrame:
|
||||||
|
"""Apply rolling average to request data."""
|
||||||
|
if window == 1:
|
||||||
|
return df
|
||||||
|
|
||||||
|
result = df.copy()
|
||||||
|
result["request_count"] = (
|
||||||
|
result.groupby("endpoint")["request_count"]
|
||||||
|
.transform(lambda x: x.rolling(window, min_periods=1).mean())
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_data(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Normalize request counts to percentages (share of total per day)."""
|
||||||
|
result = df.copy()
|
||||||
|
daily_totals = result.groupby("date")["request_count"].transform("sum")
|
||||||
|
result["request_count"] = result["request_count"] / daily_totals
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_delta(df: pd.DataFrame, endpoint: str) -> tuple[float, float | None]:
|
||||||
|
"""Calculate 28-day delta for an endpoint."""
|
||||||
|
endpoint_data = df[df["endpoint"] == endpoint].sort_values("date")
|
||||||
|
|
||||||
|
if len(endpoint_data) < 2:
|
||||||
|
return endpoint_data["request_count"].iloc[-1], None
|
||||||
|
|
||||||
|
latest = endpoint_data["request_count"].iloc[-1]
|
||||||
|
|
||||||
|
if len(endpoint_data) > 28:
|
||||||
|
previous = endpoint_data["request_count"].iloc[-29]
|
||||||
|
else:
|
||||||
|
previous = endpoint_data["request_count"].iloc[0]
|
||||||
|
|
||||||
|
delta = latest - previous
|
||||||
|
return latest, delta
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Layout
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
raw_data = load_api_data()
|
||||||
|
|
||||||
|
# Header
|
||||||
|
st.markdown("# API Usage :material/api:")
|
||||||
|
st.caption("Select an API category to explore endpoint usage over time.")
|
||||||
|
|
||||||
|
# Category selection (not centered)
|
||||||
|
category = st.segmented_control(
|
||||||
|
"Select category",
|
||||||
|
options=[
|
||||||
|
":material/person: Users",
|
||||||
|
":material/shopping_cart: Orders",
|
||||||
|
":material/inventory_2: Products",
|
||||||
|
":material/analytics: Analytics",
|
||||||
|
],
|
||||||
|
default=":material/person: Users",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
st.warning("Please select a category above.", icon=":material/warning:")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
# Map display name to category key
|
||||||
|
category_map = {
|
||||||
|
":material/person: Users": "Users",
|
||||||
|
":material/shopping_cart: Orders": "Orders",
|
||||||
|
":material/inventory_2: Products": "Products",
|
||||||
|
":material/analytics: Analytics": "Analytics",
|
||||||
|
}
|
||||||
|
selected_category = category_map[category]
|
||||||
|
|
||||||
|
st.subheader(f"{category} endpoints", divider="gray")
|
||||||
|
|
||||||
|
# Layout: filters on left, chart on right
|
||||||
|
filter_col, chart_col = st.columns([1, 2])
|
||||||
|
|
||||||
|
with filter_col:
|
||||||
|
# Metric selection
|
||||||
|
with st.expander("Metric", expanded=True, icon=":material/analytics:"):
|
||||||
|
measure = st.selectbox(
|
||||||
|
"Choose a measure",
|
||||||
|
["Request count", "Unique callers", "Error rate"],
|
||||||
|
index=0,
|
||||||
|
label_visibility="collapsed",
|
||||||
|
disabled=True, # Only one option in this template
|
||||||
|
help="In production, connect to different metrics tables",
|
||||||
|
)
|
||||||
|
|
||||||
|
rolling_label = st.segmented_control(
|
||||||
|
"Time aggregation",
|
||||||
|
list(ROLLING_OPTIONS.keys()),
|
||||||
|
default="7-day average",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
if rolling_label is None:
|
||||||
|
st.caption("Please select a time aggregation.")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
rolling_window = ROLLING_OPTIONS[rolling_label]
|
||||||
|
|
||||||
|
normalize = st.toggle(
|
||||||
|
"Normalize",
|
||||||
|
value=False,
|
||||||
|
help="Normalize to show percentage share of total requests",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Starter kits
|
||||||
|
with st.expander("Starter kits", expanded=True, icon=":material/auto_awesome:"):
|
||||||
|
starter_kit = st.pills(
|
||||||
|
"Quick select",
|
||||||
|
options=list(STARTER_KITS.keys()),
|
||||||
|
default="None",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Endpoint selection
|
||||||
|
available_endpoints = API_CATEGORIES[selected_category]
|
||||||
|
|
||||||
|
# Determine default selection based on starter kit
|
||||||
|
if starter_kit and starter_kit != "None":
|
||||||
|
default_endpoints = [e for e in STARTER_KITS[starter_kit] if e in available_endpoints]
|
||||||
|
else:
|
||||||
|
default_endpoints = available_endpoints[:4] # First 4 endpoints
|
||||||
|
|
||||||
|
with st.expander("Endpoints", expanded=True, icon=":material/checklist:"):
|
||||||
|
selected_endpoints = st.multiselect(
|
||||||
|
"Select endpoints",
|
||||||
|
options=available_endpoints,
|
||||||
|
default=default_endpoints,
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter and process data
|
||||||
|
if not selected_endpoints:
|
||||||
|
with chart_col:
|
||||||
|
st.info("Select at least one endpoint to view usage data.", icon=":material/info:")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
filtered_data = raw_data[raw_data["endpoint"].isin(selected_endpoints)].copy()
|
||||||
|
filtered_data = apply_rolling_average(filtered_data, rolling_window)
|
||||||
|
|
||||||
|
if normalize:
|
||||||
|
filtered_data = normalize_data(filtered_data)
|
||||||
|
|
||||||
|
with chart_col:
|
||||||
|
# Latest metrics
|
||||||
|
with st.expander("Latest numbers", expanded=True, icon=":material/numbers:"):
|
||||||
|
metrics_row = st.container(horizontal=True)
|
||||||
|
|
||||||
|
for endpoint in selected_endpoints:
|
||||||
|
latest, delta = calculate_delta(filtered_data, endpoint)
|
||||||
|
|
||||||
|
if normalize:
|
||||||
|
value_str = f"{latest:.2%}"
|
||||||
|
delta_str = f"{delta:+.2%}" if delta is not None else None
|
||||||
|
else:
|
||||||
|
value_str = f"{latest:,.0f}"
|
||||||
|
delta_str = f"{delta:+,.0f}" if delta is not None else None
|
||||||
|
|
||||||
|
metrics_row.metric(
|
||||||
|
label=endpoint,
|
||||||
|
value=value_str,
|
||||||
|
delta=delta_str,
|
||||||
|
border=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time series chart
|
||||||
|
with st.expander("Time series", expanded=True, icon=":material/show_chart:"):
|
||||||
|
y_format = ".1%" if normalize else ",.0f"
|
||||||
|
y_title = "Share of requests" if normalize else "Request count"
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(filtered_data)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
x=alt.X("date:T", title="Date"),
|
||||||
|
y=alt.Y("request_count:Q", title=y_title, axis=alt.Axis(format=y_format)),
|
||||||
|
color=alt.Color("endpoint:N", title="Endpoint", legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip("date:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("endpoint:N", title="Endpoint"),
|
||||||
|
alt.Tooltip("request_count:Q", title="Requests", format=y_format),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=450)
|
||||||
|
.interactive()
|
||||||
|
)
|
||||||
|
|
||||||
|
st.altair_chart(chart)
|
||||||
|
|
||||||
|
# Raw data section
|
||||||
|
with st.expander("Raw data", expanded=False, icon=":material/table:"):
|
||||||
|
display_df = filtered_data.copy()
|
||||||
|
if normalize:
|
||||||
|
display_df["request_count"] = display_df["request_count"].apply(lambda x: f"{x:.2%}")
|
||||||
|
st.dataframe(display_df, hide_index=True)
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-metrics-snowflake"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "A metrics dashboard template using Snowflake for data storage and retrieval"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
definition_version: 2
|
||||||
|
entities:
|
||||||
|
DASHBOARD_METRICS_SNOWFLAKE:
|
||||||
|
type: streamlit
|
||||||
|
identifier:
|
||||||
|
name: DASHBOARD_METRICS_SNOWFLAKE
|
||||||
|
database: <FROM_CONNECTION> # Use: snow connection list
|
||||||
|
schema: <FROM_CONNECTION>
|
||||||
|
query_warehouse: <FROM_CONNECTION>
|
||||||
|
runtime_name: SYSTEM$ST_CONTAINER_RUNTIME_PY3_11
|
||||||
|
# List available: SHOW EXTERNAL ACCESS INTEGRATIONS;
|
||||||
|
# Common: PYPI_ACCESS_INTEGRATION (verify exists)
|
||||||
|
external_access_integrations:
|
||||||
|
- <YOUR_PYPI_INTEGRATION>
|
||||||
|
main_file: streamlit_app.py
|
||||||
|
artifacts:
|
||||||
|
- streamlit_app.py
|
||||||
|
- pyproject.toml
|
||||||
+463
@@ -0,0 +1,463 @@
|
|||||||
|
"""
|
||||||
|
Metrics Dashboard Template (Snowflake Edition)
|
||||||
|
|
||||||
|
A comprehensive metrics dashboard demonstrating:
|
||||||
|
- Snowflake connection via st.connection("snowflake")
|
||||||
|
- Parameterized queries for safe data loading
|
||||||
|
- Time series visualization with Altair (line, area, bar, point charts)
|
||||||
|
- Metric cards with chart/table toggle and popover filters
|
||||||
|
- Time range filtering (1M, 6M, 1Y, QTD, YTD, All)
|
||||||
|
- Line options (Daily, 7-day MA)
|
||||||
|
|
||||||
|
This template creates synthetic data in Snowflake. You can:
|
||||||
|
1. Replace the synthetic data generation with your actual tables
|
||||||
|
2. Modify the queries to match your schema (using parameterized queries)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="Metrics Dashboard (Snowflake)",
|
||||||
|
page_icon=":material/monitoring:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
|
||||||
|
CHART_HEIGHT = 300
|
||||||
|
|
||||||
|
# Metric configurations (used for synthetic data generation)
|
||||||
|
METRIC_CONFIGS = {
|
||||||
|
"users": {"base_value": 5000, "growth_rate": 0.002},
|
||||||
|
"sessions": {"base_value": 15000, "growth_rate": 0.003},
|
||||||
|
"revenue": {"base_value": 50000, "growth_rate": 0.001},
|
||||||
|
"conversions": {"base_value": 500, "growth_rate": 0.0015},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Snowflake Connection and Data Loading
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_snowflake_connection():
|
||||||
|
"""Get Snowflake connection via st.connection.
|
||||||
|
|
||||||
|
Displays an error and stops the app if the connection fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return st.connection("snowflake")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Failed to connect to Snowflake: {e}")
|
||||||
|
st.info(
|
||||||
|
"Make sure you have configured your Snowflake connection in "
|
||||||
|
"`.streamlit/secrets.toml` or via environment variables."
|
||||||
|
)
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# SQL query template for synthetic data generation.
|
||||||
|
# Uses positional parameters (?) for Snowflake connector compatibility.
|
||||||
|
SYNTHETIC_DATA_QUERY = """
|
||||||
|
WITH date_series AS (
|
||||||
|
SELECT
|
||||||
|
DATEADD(day, -seq4(), CURRENT_DATE() - 1) AS ds
|
||||||
|
FROM TABLE(GENERATOR(ROWCOUNT => 730))
|
||||||
|
),
|
||||||
|
base_data AS (
|
||||||
|
SELECT
|
||||||
|
ds,
|
||||||
|
? * POWER(1 + ?, DATEDIFF(day, '2023-01-01', ds)) AS base_trend,
|
||||||
|
CASE WHEN DAYOFWEEK(ds) IN (0, 6) THEN 0.7 ELSE 1.0 END AS seasonality,
|
||||||
|
1 + (RANDOM() / 10000000000000000000.0 - 0.5) * 0.2 AS noise
|
||||||
|
FROM date_series
|
||||||
|
WHERE ds >= '2023-01-01'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ds,
|
||||||
|
ROUND(base_trend * seasonality * noise, 2) AS daily_value,
|
||||||
|
ROUND(AVG(base_trend * seasonality * noise) OVER (
|
||||||
|
ORDER BY ds ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
|
||||||
|
), 2) AS value_7d_ma
|
||||||
|
FROM base_data
|
||||||
|
ORDER BY ds
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600, show_spinner="Loading metrics from Snowflake...")
|
||||||
|
def load_metric_from_snowflake(metric_name: str) -> pd.DataFrame:
|
||||||
|
"""Load metric data from Snowflake using parameterized queries.
|
||||||
|
|
||||||
|
In production, replace the synthetic query with your actual table query:
|
||||||
|
|
||||||
|
PRODUCTION_QUERY = '''
|
||||||
|
SELECT ds, daily_value, value_7d_ma
|
||||||
|
FROM your_schema.your_metrics_table
|
||||||
|
WHERE metric_name = ?
|
||||||
|
ORDER BY ds
|
||||||
|
'''
|
||||||
|
|
||||||
|
df = conn.query(PRODUCTION_QUERY, params=[metric_name])
|
||||||
|
"""
|
||||||
|
conn = get_snowflake_connection()
|
||||||
|
config = METRIC_CONFIGS[metric_name]
|
||||||
|
|
||||||
|
# Use parameterized query with positional parameters (list)
|
||||||
|
df = conn.query(
|
||||||
|
SYNTHETIC_DATA_QUERY,
|
||||||
|
params=[config["base_value"], config["growth_rate"]],
|
||||||
|
)
|
||||||
|
df.columns = df.columns.str.lower() # Normalize column names
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def load_all_metrics() -> dict[str, pd.DataFrame]:
|
||||||
|
"""Load all metrics from Snowflake."""
|
||||||
|
return {
|
||||||
|
"users": load_metric_from_snowflake("users"),
|
||||||
|
"sessions": load_metric_from_snowflake("sessions"),
|
||||||
|
"revenue": load_metric_from_snowflake("revenue"),
|
||||||
|
"conversions": load_metric_from_snowflake("conversions"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Chart Utilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
|
||||||
|
"""Filter dataframe by time range."""
|
||||||
|
if time_range == "All" or df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df[x_col] = pd.to_datetime(df[x_col])
|
||||||
|
max_date = df[x_col].max()
|
||||||
|
|
||||||
|
if time_range == "1M":
|
||||||
|
min_date = max_date - timedelta(days=30)
|
||||||
|
elif time_range == "6M":
|
||||||
|
min_date = max_date - timedelta(days=180)
|
||||||
|
elif time_range == "1Y":
|
||||||
|
min_date = max_date - timedelta(days=365)
|
||||||
|
elif time_range == "QTD":
|
||||||
|
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
|
||||||
|
elif time_range == "YTD":
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, 1, 1))
|
||||||
|
else:
|
||||||
|
return df
|
||||||
|
|
||||||
|
return df[df[x_col] >= min_date]
|
||||||
|
|
||||||
|
|
||||||
|
def render_line_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_cols: list[str],
|
||||||
|
labels: list[str],
|
||||||
|
height: int = CHART_HEIGHT,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Render a multi-line chart."""
|
||||||
|
# Melt for Altair
|
||||||
|
melted = df.melt(
|
||||||
|
id_vars=[x_col],
|
||||||
|
value_vars=y_cols,
|
||||||
|
var_name="series",
|
||||||
|
value_name="value",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map to labels
|
||||||
|
label_map = dict(zip(y_cols, labels))
|
||||||
|
melted["series"] = melted["series"].map(label_map)
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(melted)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
|
||||||
|
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
|
||||||
|
strokeDash=alt.condition(
|
||||||
|
alt.datum.series == "7-day MA",
|
||||||
|
alt.value([5, 5]),
|
||||||
|
alt.value([0]),
|
||||||
|
),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("series:N", title="Series"),
|
||||||
|
alt.Tooltip("value:Q", title="Value", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def render_area_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_cols: list[str],
|
||||||
|
labels: list[str],
|
||||||
|
height: int = CHART_HEIGHT,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Render a stacked area chart."""
|
||||||
|
melted = df.melt(
|
||||||
|
id_vars=[x_col],
|
||||||
|
value_vars=y_cols,
|
||||||
|
var_name="series",
|
||||||
|
value_name="value",
|
||||||
|
)
|
||||||
|
label_map = dict(zip(y_cols, labels))
|
||||||
|
melted["series"] = melted["series"].map(label_map)
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(melted)
|
||||||
|
.mark_area(opacity=0.6, line=True)
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
|
||||||
|
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("series:N", title="Series"),
|
||||||
|
alt.Tooltip("value:Q", title="Value", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def render_bar_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_cols: list[str],
|
||||||
|
labels: list[str],
|
||||||
|
height: int = CHART_HEIGHT,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Render a bar chart (weekly aggregation for readability)."""
|
||||||
|
df = df.copy()
|
||||||
|
df[x_col] = pd.to_datetime(df[x_col])
|
||||||
|
df["week"] = df[x_col].dt.to_period("W").dt.start_time
|
||||||
|
|
||||||
|
# Aggregate by week
|
||||||
|
agg_df = df.groupby("week")[y_cols].mean().reset_index()
|
||||||
|
|
||||||
|
melted = agg_df.melt(
|
||||||
|
id_vars=["week"],
|
||||||
|
value_vars=y_cols,
|
||||||
|
var_name="series",
|
||||||
|
value_name="value",
|
||||||
|
)
|
||||||
|
label_map = dict(zip(y_cols, labels))
|
||||||
|
melted["series"] = melted["series"].map(label_map)
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(melted)
|
||||||
|
.mark_bar(opacity=0.8)
|
||||||
|
.encode(
|
||||||
|
x=alt.X("week:T", title=None),
|
||||||
|
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
|
||||||
|
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
|
||||||
|
xOffset="series:N",
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip("week:T", title="Week", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("series:N", title="Series"),
|
||||||
|
alt.Tooltip("value:Q", title="Value", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def render_point_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_cols: list[str],
|
||||||
|
labels: list[str],
|
||||||
|
height: int = CHART_HEIGHT,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Render a scatter/point chart with trend line."""
|
||||||
|
melted = df.melt(
|
||||||
|
id_vars=[x_col],
|
||||||
|
value_vars=y_cols,
|
||||||
|
var_name="series",
|
||||||
|
value_name="value",
|
||||||
|
)
|
||||||
|
label_map = dict(zip(y_cols, labels))
|
||||||
|
melted["series"] = melted["series"].map(label_map)
|
||||||
|
|
||||||
|
points = (
|
||||||
|
alt.Chart(melted)
|
||||||
|
.mark_point(opacity=0.5, size=20)
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
|
||||||
|
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("series:N", title="Series"),
|
||||||
|
alt.Tooltip("value:Q", title="Value", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add trend line for 7-day MA only
|
||||||
|
trend = (
|
||||||
|
alt.Chart(melted[melted["series"] == "7-day MA"])
|
||||||
|
.mark_line(strokeDash=[5, 5], strokeWidth=2)
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T"),
|
||||||
|
y=alt.Y("value:Q"),
|
||||||
|
color=alt.Color("series:N"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (points + trend).properties(height=height)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Metric Card Component
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def metric_card(
|
||||||
|
title: str,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
key_prefix: str,
|
||||||
|
chart_type: str = "line",
|
||||||
|
):
|
||||||
|
"""Display a metric card with chart/table toggle and popover filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Card title
|
||||||
|
df: DataFrame with ds, daily_value, value_7d_ma columns
|
||||||
|
key_prefix: Unique prefix for widget keys
|
||||||
|
chart_type: One of "line", "area", "bar", "point"
|
||||||
|
"""
|
||||||
|
chart_renderers = {
|
||||||
|
"line": render_line_chart,
|
||||||
|
"area": render_area_chart,
|
||||||
|
"bar": render_bar_chart,
|
||||||
|
"point": render_point_chart,
|
||||||
|
}
|
||||||
|
render_chart = chart_renderers.get(chart_type, render_line_chart)
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
# Header row with title, view toggle, and filters
|
||||||
|
with st.container(
|
||||||
|
horizontal=True,
|
||||||
|
horizontal_alignment="distribute",
|
||||||
|
vertical_alignment="center",
|
||||||
|
):
|
||||||
|
st.markdown(f"**{title}**")
|
||||||
|
|
||||||
|
view_mode = st.segmented_control(
|
||||||
|
"View",
|
||||||
|
options=[":material/show_chart:", ":material/table:"],
|
||||||
|
default=":material/show_chart:",
|
||||||
|
key=f"{key_prefix}_view",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
line_options = st.pills(
|
||||||
|
"Lines",
|
||||||
|
options=["Daily", "7-day MA"],
|
||||||
|
default=["Daily", "7-day MA"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key=f"{key_prefix}_lines",
|
||||||
|
)
|
||||||
|
time_range = st.segmented_control(
|
||||||
|
"Time range",
|
||||||
|
options=TIME_RANGES,
|
||||||
|
default="All",
|
||||||
|
key=f"{key_prefix}_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
line_options = line_options or ["7-day MA"]
|
||||||
|
filtered_df = filter_by_time_range(df, "ds", time_range)
|
||||||
|
|
||||||
|
# Determine which columns to show
|
||||||
|
y_cols = []
|
||||||
|
labels = []
|
||||||
|
if "Daily" in line_options:
|
||||||
|
y_cols.append("daily_value")
|
||||||
|
labels.append("Daily")
|
||||||
|
if "7-day MA" in line_options:
|
||||||
|
y_cols.append("value_7d_ma")
|
||||||
|
labels.append("7-day MA")
|
||||||
|
|
||||||
|
# Render view
|
||||||
|
if "table" in (view_mode or ""):
|
||||||
|
st.dataframe(
|
||||||
|
filtered_df,
|
||||||
|
height=CHART_HEIGHT,
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if y_cols:
|
||||||
|
st.altair_chart(
|
||||||
|
render_chart(filtered_df, "ds", y_cols, labels),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.info("Select at least one line option.")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Header Component
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_header(title: str):
|
||||||
|
"""Render page header with title and reset button."""
|
||||||
|
with st.container(
|
||||||
|
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
|
||||||
|
):
|
||||||
|
st.markdown(title)
|
||||||
|
if st.button(":material/restart_alt: Reset", type="tertiary"):
|
||||||
|
st.session_state.clear()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Layout
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Load data from Snowflake
|
||||||
|
metrics_data = load_all_metrics()
|
||||||
|
|
||||||
|
# Page header
|
||||||
|
render_page_header("# :material/monitoring: Metrics Dashboard")
|
||||||
|
st.caption(":material/cloud: Powered by Snowflake")
|
||||||
|
|
||||||
|
# Row 1: Users and Sessions
|
||||||
|
row1 = st.columns(2)
|
||||||
|
|
||||||
|
with row1[0]:
|
||||||
|
metric_card("Active Users", metrics_data["users"], "users", chart_type="line")
|
||||||
|
|
||||||
|
with row1[1]:
|
||||||
|
metric_card("Sessions", metrics_data["sessions"], "sessions", chart_type="area")
|
||||||
|
|
||||||
|
# Row 2: Revenue and Conversions
|
||||||
|
row2 = st.columns(2)
|
||||||
|
|
||||||
|
with row2[0]:
|
||||||
|
metric_card("Revenue", metrics_data["revenue"], "revenue", chart_type="bar")
|
||||||
|
|
||||||
|
with row2[1]:
|
||||||
|
metric_card("Conversions", metrics_data["conversions"], "conversions", chart_type="point")
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-metrics"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "A metrics dashboard template showing time series with sparklines and filtering"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
+426
@@ -0,0 +1,426 @@
|
|||||||
|
"""
|
||||||
|
Metrics Dashboard Template
|
||||||
|
|
||||||
|
A comprehensive metrics dashboard demonstrating:
|
||||||
|
- Time series visualization with Altair (line, area, bar, point charts)
|
||||||
|
- Metric cards with chart/table toggle and popover filters
|
||||||
|
- Time range filtering (1M, 6M, 1Y, QTD, YTD, All)
|
||||||
|
- Line options (Daily, 7-day MA)
|
||||||
|
|
||||||
|
This template uses synthetic data. Replace the generate_*_data() functions
|
||||||
|
with your own data sources (e.g., Snowflake queries, APIs, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="Metrics Dashboard",
|
||||||
|
page_icon=":material/monitoring:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
|
||||||
|
CHART_HEIGHT = 300
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Synthetic Data Generation (Replace with your data source)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def generate_metric_data(
|
||||||
|
metric_name: str,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
base_value: float = 1000,
|
||||||
|
growth_rate: float = 0.001,
|
||||||
|
noise_factor: float = 0.1,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Generate synthetic time series data for a metric.
|
||||||
|
|
||||||
|
Replace this function with your actual data source, e.g.:
|
||||||
|
- Snowflake query
|
||||||
|
- API call
|
||||||
|
- Database query
|
||||||
|
"""
|
||||||
|
np.random.seed(hash(metric_name) % 2**32)
|
||||||
|
|
||||||
|
dates = pd.date_range(start=start_date, end=end_date, freq="D")
|
||||||
|
n_days = len(dates)
|
||||||
|
|
||||||
|
# Base trend with growth
|
||||||
|
trend = base_value * (1 + growth_rate) ** np.arange(n_days)
|
||||||
|
|
||||||
|
# Add weekly seasonality (lower on weekends)
|
||||||
|
day_of_week = dates.dayofweek
|
||||||
|
seasonality = np.where(day_of_week >= 5, 0.7, 1.0)
|
||||||
|
trend = trend * seasonality
|
||||||
|
|
||||||
|
# Add noise
|
||||||
|
noise = np.random.normal(1, noise_factor, n_days)
|
||||||
|
values = trend * noise
|
||||||
|
|
||||||
|
# Calculate rolling averages
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"ds": dates,
|
||||||
|
"daily_value": values,
|
||||||
|
})
|
||||||
|
df["value_7d_ma"] = df["daily_value"].rolling(7, min_periods=1).mean()
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600)
|
||||||
|
def load_all_metrics() -> dict[str, pd.DataFrame]:
|
||||||
|
"""Load all metrics data. Replace with your data loading logic."""
|
||||||
|
end_date = date.today() - timedelta(days=1)
|
||||||
|
start_date = end_date - timedelta(days=730) # 2 years of data
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users": generate_metric_data("users", start_date, end_date, base_value=5000, growth_rate=0.002),
|
||||||
|
"sessions": generate_metric_data("sessions", start_date, end_date, base_value=15000, growth_rate=0.003),
|
||||||
|
"revenue": generate_metric_data("revenue", start_date, end_date, base_value=50000, growth_rate=0.001),
|
||||||
|
"conversions": generate_metric_data("conversions", start_date, end_date, base_value=500, growth_rate=0.0015),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Chart Utilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
|
||||||
|
"""Filter dataframe by time range."""
|
||||||
|
if time_range == "All" or df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df[x_col] = pd.to_datetime(df[x_col])
|
||||||
|
max_date = df[x_col].max()
|
||||||
|
|
||||||
|
if time_range == "1M":
|
||||||
|
min_date = max_date - timedelta(days=30)
|
||||||
|
elif time_range == "6M":
|
||||||
|
min_date = max_date - timedelta(days=180)
|
||||||
|
elif time_range == "1Y":
|
||||||
|
min_date = max_date - timedelta(days=365)
|
||||||
|
elif time_range == "QTD":
|
||||||
|
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
|
||||||
|
elif time_range == "YTD":
|
||||||
|
min_date = pd.Timestamp(date(max_date.year, 1, 1))
|
||||||
|
else:
|
||||||
|
return df
|
||||||
|
|
||||||
|
return df[df[x_col] >= min_date]
|
||||||
|
|
||||||
|
|
||||||
|
def render_line_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_cols: list[str],
|
||||||
|
labels: list[str],
|
||||||
|
height: int = CHART_HEIGHT,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Render a multi-line chart."""
|
||||||
|
# Melt for Altair
|
||||||
|
melted = df.melt(
|
||||||
|
id_vars=[x_col],
|
||||||
|
value_vars=y_cols,
|
||||||
|
var_name="series",
|
||||||
|
value_name="value",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map to labels
|
||||||
|
label_map = dict(zip(y_cols, labels))
|
||||||
|
melted["series"] = melted["series"].map(label_map)
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(melted)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
|
||||||
|
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
|
||||||
|
strokeDash=alt.condition(
|
||||||
|
alt.datum.series == "7-day MA",
|
||||||
|
alt.value([5, 5]),
|
||||||
|
alt.value([0]),
|
||||||
|
),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("series:N", title="Series"),
|
||||||
|
alt.Tooltip("value:Q", title="Value", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def render_area_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_cols: list[str],
|
||||||
|
labels: list[str],
|
||||||
|
height: int = CHART_HEIGHT,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Render a stacked area chart."""
|
||||||
|
melted = df.melt(
|
||||||
|
id_vars=[x_col],
|
||||||
|
value_vars=y_cols,
|
||||||
|
var_name="series",
|
||||||
|
value_name="value",
|
||||||
|
)
|
||||||
|
label_map = dict(zip(y_cols, labels))
|
||||||
|
melted["series"] = melted["series"].map(label_map)
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(melted)
|
||||||
|
.mark_area(opacity=0.6, line=True)
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
|
||||||
|
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("series:N", title="Series"),
|
||||||
|
alt.Tooltip("value:Q", title="Value", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def render_bar_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_cols: list[str],
|
||||||
|
labels: list[str],
|
||||||
|
height: int = CHART_HEIGHT,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Render a bar chart (weekly aggregation for readability)."""
|
||||||
|
df = df.copy()
|
||||||
|
df[x_col] = pd.to_datetime(df[x_col])
|
||||||
|
df["week"] = df[x_col].dt.to_period("W").dt.start_time
|
||||||
|
|
||||||
|
# Aggregate by week
|
||||||
|
agg_df = df.groupby("week")[y_cols].mean().reset_index()
|
||||||
|
|
||||||
|
melted = agg_df.melt(
|
||||||
|
id_vars=["week"],
|
||||||
|
value_vars=y_cols,
|
||||||
|
var_name="series",
|
||||||
|
value_name="value",
|
||||||
|
)
|
||||||
|
label_map = dict(zip(y_cols, labels))
|
||||||
|
melted["series"] = melted["series"].map(label_map)
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(melted)
|
||||||
|
.mark_bar(opacity=0.8)
|
||||||
|
.encode(
|
||||||
|
x=alt.X("week:T", title=None),
|
||||||
|
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
|
||||||
|
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
|
||||||
|
xOffset="series:N",
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip("week:T", title="Week", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("series:N", title="Series"),
|
||||||
|
alt.Tooltip("value:Q", title="Value", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def render_point_chart(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
x_col: str,
|
||||||
|
y_cols: list[str],
|
||||||
|
labels: list[str],
|
||||||
|
height: int = CHART_HEIGHT,
|
||||||
|
) -> alt.Chart:
|
||||||
|
"""Render a scatter/point chart with trend line."""
|
||||||
|
melted = df.melt(
|
||||||
|
id_vars=[x_col],
|
||||||
|
value_vars=y_cols,
|
||||||
|
var_name="series",
|
||||||
|
value_name="value",
|
||||||
|
)
|
||||||
|
label_map = dict(zip(y_cols, labels))
|
||||||
|
melted["series"] = melted["series"].map(label_map)
|
||||||
|
|
||||||
|
points = (
|
||||||
|
alt.Chart(melted)
|
||||||
|
.mark_point(opacity=0.5, size=20)
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T", title=None),
|
||||||
|
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
|
||||||
|
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
|
||||||
|
alt.Tooltip("series:N", title="Series"),
|
||||||
|
alt.Tooltip("value:Q", title="Value", format=",.0f"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add trend line for 7-day MA only
|
||||||
|
trend = (
|
||||||
|
alt.Chart(melted[melted["series"] == "7-day MA"])
|
||||||
|
.mark_line(strokeDash=[5, 5], strokeWidth=2)
|
||||||
|
.encode(
|
||||||
|
x=alt.X(f"{x_col}:T"),
|
||||||
|
y=alt.Y("value:Q"),
|
||||||
|
color=alt.Color("series:N"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (points + trend).properties(height=height)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Metric Card Component
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def metric_card(
|
||||||
|
title: str,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
key_prefix: str,
|
||||||
|
chart_type: str = "line",
|
||||||
|
):
|
||||||
|
"""Display a metric card with chart/table toggle and popover filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Card title
|
||||||
|
df: DataFrame with ds, daily_value, value_7d_ma columns
|
||||||
|
key_prefix: Unique prefix for widget keys
|
||||||
|
chart_type: One of "line", "area", "bar", "point"
|
||||||
|
"""
|
||||||
|
chart_renderers = {
|
||||||
|
"line": render_line_chart,
|
||||||
|
"area": render_area_chart,
|
||||||
|
"bar": render_bar_chart,
|
||||||
|
"point": render_point_chart,
|
||||||
|
}
|
||||||
|
render_chart = chart_renderers.get(chart_type, render_line_chart)
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
# Header row with title, view toggle, and filters
|
||||||
|
with st.container(
|
||||||
|
horizontal=True,
|
||||||
|
horizontal_alignment="distribute",
|
||||||
|
vertical_alignment="center",
|
||||||
|
):
|
||||||
|
st.markdown(f"**{title}**")
|
||||||
|
|
||||||
|
view_mode = st.segmented_control(
|
||||||
|
"View",
|
||||||
|
options=[":material/show_chart:", ":material/table:"],
|
||||||
|
default=":material/show_chart:",
|
||||||
|
key=f"{key_prefix}_view",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.popover("Filters", type="tertiary"):
|
||||||
|
line_options = st.pills(
|
||||||
|
"Lines",
|
||||||
|
options=["Daily", "7-day MA"],
|
||||||
|
default=["Daily", "7-day MA"],
|
||||||
|
selection_mode="multi",
|
||||||
|
key=f"{key_prefix}_lines",
|
||||||
|
)
|
||||||
|
time_range = st.segmented_control(
|
||||||
|
"Time range",
|
||||||
|
options=TIME_RANGES,
|
||||||
|
default="All",
|
||||||
|
key=f"{key_prefix}_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
line_options = line_options or ["7-day MA"]
|
||||||
|
filtered_df = filter_by_time_range(df, "ds", time_range)
|
||||||
|
|
||||||
|
# Determine which columns to show
|
||||||
|
y_cols = []
|
||||||
|
labels = []
|
||||||
|
if "Daily" in line_options:
|
||||||
|
y_cols.append("daily_value")
|
||||||
|
labels.append("Daily")
|
||||||
|
if "7-day MA" in line_options:
|
||||||
|
y_cols.append("value_7d_ma")
|
||||||
|
labels.append("7-day MA")
|
||||||
|
|
||||||
|
# Render view
|
||||||
|
if "table" in (view_mode or ""):
|
||||||
|
st.dataframe(
|
||||||
|
filtered_df,
|
||||||
|
height=CHART_HEIGHT,
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if y_cols:
|
||||||
|
st.altair_chart(
|
||||||
|
render_chart(filtered_df, "ds", y_cols, labels),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.info("Select at least one line option.")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Header Component
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_header(title: str):
|
||||||
|
"""Render page header with title and reset button."""
|
||||||
|
with st.container(
|
||||||
|
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
|
||||||
|
):
|
||||||
|
st.markdown(title)
|
||||||
|
if st.button(":material/restart_alt: Reset", type="tertiary"):
|
||||||
|
st.session_state.clear()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Layout
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Load data (cached)
|
||||||
|
metrics_data = load_all_metrics()
|
||||||
|
|
||||||
|
# Page header
|
||||||
|
render_page_header("# :material/monitoring: Metrics Dashboard")
|
||||||
|
|
||||||
|
# Row 1: Users and Sessions
|
||||||
|
row1 = st.columns(2)
|
||||||
|
|
||||||
|
with row1[0]:
|
||||||
|
metric_card("Active Users", metrics_data["users"], "users", chart_type="line")
|
||||||
|
|
||||||
|
with row1[1]:
|
||||||
|
metric_card("Sessions", metrics_data["sessions"], "sessions", chart_type="area")
|
||||||
|
|
||||||
|
# Row 2: Revenue and Conversions
|
||||||
|
row2 = st.columns(2)
|
||||||
|
|
||||||
|
with row2[0]:
|
||||||
|
metric_card("Revenue", metrics_data["revenue"], "revenue", chart_type="bar")
|
||||||
|
|
||||||
|
with row2[1]:
|
||||||
|
metric_card("Conversions", metrics_data["conversions"], "conversions", chart_type="point")
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-seattle-weather"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "An example dashboard exploring the Seattle Weather dataset"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
"vega-datasets>=0.9.0",
|
||||||
|
]
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import altair as alt
|
||||||
|
import vega_datasets
|
||||||
|
|
||||||
|
|
||||||
|
full_df = vega_datasets.data("seattle_weather")
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
# Title and icon for the browser's tab bar:
|
||||||
|
page_title="Seattle Weather",
|
||||||
|
page_icon=":mostly_sunny:",
|
||||||
|
# Make the content take up the width of the page:
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Seattle Weather
|
||||||
|
|
||||||
|
Let's explore the [classic Seattle Weather
|
||||||
|
dataset](https://altair-viz.github.io/case_studies/exploring-weather.html)!
|
||||||
|
"""
|
||||||
|
|
||||||
|
"" # Add a little vertical space. Same as st.write("").
|
||||||
|
""
|
||||||
|
|
||||||
|
"""
|
||||||
|
## 2015 Summary
|
||||||
|
"""
|
||||||
|
|
||||||
|
""
|
||||||
|
|
||||||
|
df_2015 = full_df[full_df["date"].dt.year == 2015]
|
||||||
|
df_2014 = full_df[full_df["date"].dt.year == 2014]
|
||||||
|
|
||||||
|
max_temp_2015 = df_2015["temp_max"].max()
|
||||||
|
max_temp_2014 = df_2014["temp_max"].max()
|
||||||
|
|
||||||
|
min_temp_2015 = df_2015["temp_min"].min()
|
||||||
|
min_temp_2014 = df_2014["temp_min"].min()
|
||||||
|
|
||||||
|
max_wind_2015 = df_2015["wind"].max()
|
||||||
|
max_wind_2014 = df_2014["wind"].max()
|
||||||
|
|
||||||
|
min_wind_2015 = df_2015["wind"].min()
|
||||||
|
min_wind_2014 = df_2014["wind"].min()
|
||||||
|
|
||||||
|
max_prec_2015 = df_2015["precipitation"].max()
|
||||||
|
max_prec_2014 = df_2014["precipitation"].max()
|
||||||
|
|
||||||
|
min_prec_2015 = df_2015["precipitation"].min()
|
||||||
|
min_prec_2014 = df_2014["precipitation"].min()
|
||||||
|
|
||||||
|
|
||||||
|
with st.container(horizontal=True, gap="medium"):
|
||||||
|
cols = st.columns(2, gap="medium", width=300)
|
||||||
|
|
||||||
|
with cols[0]:
|
||||||
|
st.metric(
|
||||||
|
"Max temperature",
|
||||||
|
f"{max_temp_2015:0.1f}C",
|
||||||
|
delta=f"{max_temp_2015 - max_temp_2014:0.1f}C",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with cols[1]:
|
||||||
|
st.metric(
|
||||||
|
"Min temperature",
|
||||||
|
f"{min_temp_2015:0.1f}C",
|
||||||
|
delta=f"{min_temp_2015 - min_temp_2014:0.1f}C",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
cols = st.columns(2, gap="medium", width=300)
|
||||||
|
|
||||||
|
with cols[0]:
|
||||||
|
st.metric(
|
||||||
|
"Max precipitation",
|
||||||
|
f"{max_prec_2015:0.1f}mm",
|
||||||
|
delta=f"{max_prec_2015 - max_prec_2014:0.1f}mm",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with cols[1]:
|
||||||
|
st.metric(
|
||||||
|
"Min precipitation",
|
||||||
|
f"{min_prec_2015:0.1f}mm",
|
||||||
|
delta=f"{min_prec_2015 - min_prec_2014:0.1f}mm",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
cols = st.columns(2, gap="medium", width=300)
|
||||||
|
|
||||||
|
with cols[0]:
|
||||||
|
st.metric(
|
||||||
|
"Max wind",
|
||||||
|
f"{max_wind_2015:0.1f}m/s",
|
||||||
|
delta=f"{max_wind_2015 - max_wind_2014:0.1f}m/s",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with cols[1]:
|
||||||
|
st.metric(
|
||||||
|
"Min wind",
|
||||||
|
f"{min_wind_2015:0.1f}m/s",
|
||||||
|
delta=f"{min_wind_2015 - min_wind_2014:0.1f}m/s",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
weather_icons = {
|
||||||
|
"sun": "sunny",
|
||||||
|
"snow": "weather_snowy",
|
||||||
|
"rain": "rainy",
|
||||||
|
"fog": "foggy",
|
||||||
|
"drizzle": "rainy",
|
||||||
|
}
|
||||||
|
|
||||||
|
cols = st.columns(2, gap="large")
|
||||||
|
|
||||||
|
with cols[0]:
|
||||||
|
weather_name = (
|
||||||
|
full_df["weather"].value_counts().head(1).reset_index()["weather"][0]
|
||||||
|
)
|
||||||
|
st.metric(
|
||||||
|
"Most common weather",
|
||||||
|
f":material/{weather_icons[weather_name]}: {weather_name.upper()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
with cols[1]:
|
||||||
|
weather_name = (
|
||||||
|
full_df["weather"].value_counts().tail(1).reset_index()["weather"][0]
|
||||||
|
)
|
||||||
|
st.metric(
|
||||||
|
"Least common weather",
|
||||||
|
f":material/{weather_icons[weather_name]}: {weather_name.upper()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
""
|
||||||
|
""
|
||||||
|
|
||||||
|
"""
|
||||||
|
## Compare different years
|
||||||
|
"""
|
||||||
|
|
||||||
|
YEARS = full_df["date"].dt.year.unique()
|
||||||
|
selected_years = st.pills(
|
||||||
|
"Years to compare", YEARS, default=YEARS, selection_mode="multi"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not selected_years:
|
||||||
|
st.warning("You must select at least 1 year.", icon=":material/warning:")
|
||||||
|
|
||||||
|
df = full_df[full_df["date"].dt.year.isin(selected_years)]
|
||||||
|
|
||||||
|
cols = st.columns([3, 1])
|
||||||
|
|
||||||
|
with cols[0].container(border=True, height="stretch"):
|
||||||
|
"### 🌡️ Temperature"
|
||||||
|
|
||||||
|
st.altair_chart(
|
||||||
|
alt.Chart(df)
|
||||||
|
.mark_bar(width=1)
|
||||||
|
.encode(
|
||||||
|
alt.X("monthdate(date):T").title("date"),
|
||||||
|
alt.Y("temp_max:Q").title("temperature range (C)"),
|
||||||
|
alt.Y2("temp_min:Q"),
|
||||||
|
alt.Color("year(date):N").title("year"),
|
||||||
|
alt.XOffset("year(date):N"),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip("monthdate(date):T", title="Date"),
|
||||||
|
alt.Tooltip("temp_max:Q", title="Max Temp (C)"),
|
||||||
|
alt.Tooltip("temp_min:Q", title="Min Temp (C)"),
|
||||||
|
alt.Tooltip("year(date):N", title="Year"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.configure_legend(orient="bottom")
|
||||||
|
)
|
||||||
|
|
||||||
|
with cols[1].container(border=True, height="stretch"):
|
||||||
|
"### Weather distribution"
|
||||||
|
|
||||||
|
st.altair_chart(
|
||||||
|
alt.Chart(df)
|
||||||
|
.mark_arc()
|
||||||
|
.encode(
|
||||||
|
alt.Theta("count()"),
|
||||||
|
alt.Color("weather:N"),
|
||||||
|
)
|
||||||
|
.configure_legend(orient="bottom")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
cols = st.columns(2)
|
||||||
|
|
||||||
|
with cols[0].container(border=True, height="stretch"):
|
||||||
|
"### 💨 Wind"
|
||||||
|
|
||||||
|
# Prepare data for st.line_chart - pivot by year
|
||||||
|
wind_df = df.copy()
|
||||||
|
wind_df["month_day"] = wind_df["date"].dt.strftime("%m-%d")
|
||||||
|
wind_df["year"] = wind_df["date"].dt.year
|
||||||
|
|
||||||
|
# Calculate 14-day rolling average per year
|
||||||
|
wind_pivot = wind_df.pivot_table(
|
||||||
|
index="month_day",
|
||||||
|
columns="year",
|
||||||
|
values="wind",
|
||||||
|
aggfunc="mean"
|
||||||
|
).sort_index()
|
||||||
|
|
||||||
|
st.line_chart(wind_pivot, height=300)
|
||||||
|
|
||||||
|
with cols[1].container(border=True, height="stretch"):
|
||||||
|
"### 🌧️ Precipitation"
|
||||||
|
|
||||||
|
st.altair_chart(
|
||||||
|
alt.Chart(df)
|
||||||
|
.mark_bar()
|
||||||
|
.encode(
|
||||||
|
alt.X("month(date):O").title("month"),
|
||||||
|
alt.Y("sum(precipitation):Q").title("precipitation (mm)"),
|
||||||
|
alt.Color("year(date):N").title("year"),
|
||||||
|
tooltip=[
|
||||||
|
alt.Tooltip("month(date):O", title="Month"),
|
||||||
|
alt.Tooltip("sum(precipitation):Q", title="Precipitation (mm)"),
|
||||||
|
alt.Tooltip("year(date):N", title="Year"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.configure_legend(orient="bottom")
|
||||||
|
)
|
||||||
|
|
||||||
|
cols = st.columns(2)
|
||||||
|
|
||||||
|
with cols[0].container(border=True, height="stretch"):
|
||||||
|
"### Monthly weather breakdown"
|
||||||
|
""
|
||||||
|
|
||||||
|
st.altair_chart(
|
||||||
|
alt.Chart(df)
|
||||||
|
.mark_bar()
|
||||||
|
.encode(
|
||||||
|
alt.X("month(date):O", title="month"),
|
||||||
|
alt.Y("count():Q", title="days").stack("normalize"),
|
||||||
|
alt.Color("weather:N"),
|
||||||
|
)
|
||||||
|
.configure_legend(orient="bottom")
|
||||||
|
)
|
||||||
|
|
||||||
|
with cols[1].container(border=True, height="stretch"):
|
||||||
|
"### Raw data"
|
||||||
|
|
||||||
|
st.dataframe(df)
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-stock-peers-snowflake"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Stock peer analysis dashboard with Snowflake connection"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
definition_version: 2
|
||||||
|
entities:
|
||||||
|
DASHBOARD_STOCK_PEERS_SNOWFLAKE:
|
||||||
|
type: streamlit
|
||||||
|
identifier:
|
||||||
|
name: DASHBOARD_STOCK_PEERS_SNOWFLAKE
|
||||||
|
database: <FROM_CONNECTION> # Use: snow connection list
|
||||||
|
schema: <FROM_CONNECTION>
|
||||||
|
query_warehouse: <FROM_CONNECTION>
|
||||||
|
runtime_name: SYSTEM$ST_CONTAINER_RUNTIME_PY3_11
|
||||||
|
# List available: SHOW EXTERNAL ACCESS INTEGRATIONS;
|
||||||
|
# Common: PYPI_ACCESS_INTEGRATION (verify exists)
|
||||||
|
external_access_integrations:
|
||||||
|
- <YOUR_PYPI_INTEGRATION>
|
||||||
|
main_file: streamlit_app.py
|
||||||
|
artifacts:
|
||||||
|
- streamlit_app.py
|
||||||
|
- pyproject.toml
|
||||||
+384
@@ -0,0 +1,384 @@
|
|||||||
|
"""
|
||||||
|
Stock Peer Analysis Dashboard (Snowflake Edition)
|
||||||
|
|
||||||
|
A stock comparison dashboard demonstrating:
|
||||||
|
- Snowflake connection via st.connection("snowflake")
|
||||||
|
- Generating synthetic stock data in Snowflake
|
||||||
|
- Normalized price comparison charts
|
||||||
|
- Individual stock vs peer average analysis
|
||||||
|
|
||||||
|
This template uses synthetic stock data generated in Snowflake.
|
||||||
|
Replace the synthetic query with your actual stock data table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
import pandas as pd
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="Stock peer analysis dashboard",
|
||||||
|
page_icon=":chart_with_upwards_trend:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
# :material/query_stats: Stock peer analysis
|
||||||
|
|
||||||
|
Easily compare stocks against others in their peer group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
"" # Add some space.
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Snowflake Connection
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_snowflake_connection():
|
||||||
|
"""Get Snowflake connection via st.connection.
|
||||||
|
|
||||||
|
Displays an error and stops the app if the connection fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return st.connection("snowflake")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Failed to connect to Snowflake: {e}")
|
||||||
|
st.info(
|
||||||
|
"Make sure you have configured your Snowflake connection in "
|
||||||
|
"`.streamlit/secrets.toml` or via environment variables."
|
||||||
|
)
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants and Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
STOCKS = [
|
||||||
|
"AAPL", "ABBV", "ACN", "ADBE", "ADP", "AMD", "AMGN", "AMT", "AMZN", "APD",
|
||||||
|
"AVGO", "AXP", "BA", "BK", "BKNG", "BMY", "BSX", "C", "CAT", "CI",
|
||||||
|
"CL", "CMCSA", "COST", "CRM", "CSCO", "CVX", "DE", "DHR", "DIS", "DUK",
|
||||||
|
"ELV", "EOG", "EQR", "FDX", "GD", "GE", "GILD", "GOOG", "GOOGL", "HD",
|
||||||
|
"HON", "HUM", "IBM", "ICE", "INTC", "ISRG", "JNJ", "JPM", "KO", "LIN",
|
||||||
|
"LLY", "LMT", "LOW", "MA", "MCD", "MDLZ", "META", "MMC", "MO", "MRK",
|
||||||
|
"MSFT", "NEE", "NFLX", "NKE", "NOW", "NVDA", "ORCL", "PEP", "PFE", "PG",
|
||||||
|
"PLD", "PM", "PSA", "REGN", "RTX", "SBUX", "SCHW", "SLB", "SO", "SPGI",
|
||||||
|
"T", "TJX", "TMO", "TSLA", "TXN", "UNH", "UNP", "UPS", "V", "VZ",
|
||||||
|
"WFC", "WM", "WMT", "XOM",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Base prices for synthetic data (approximate real values for realism)
|
||||||
|
STOCK_BASE_PRICES = {
|
||||||
|
"AAPL": 175, "MSFT": 380, "GOOGL": 140, "AMZN": 180, "NVDA": 500,
|
||||||
|
"META": 350, "TSLA": 250, "JPM": 170, "V": 280, "UNH": 520,
|
||||||
|
"HD": 350, "PG": 160, "MA": 450, "COST": 580, "ABBV": 170,
|
||||||
|
"MRK": 120, "AVGO": 900, "PEP": 180, "KO": 60, "TMO": 550,
|
||||||
|
"ADBE": 550, "CRM": 280, "CSCO": 50, "ACN": 340, "NKE": 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_STOCKS = ["AAPL", "MSFT", "GOOGL", "NVDA", "AMZN", "TSLA", "META"]
|
||||||
|
|
||||||
|
# Time horizon mapping
|
||||||
|
HORIZON_MAP = {
|
||||||
|
"1 Month": 30,
|
||||||
|
"3 Months": 90,
|
||||||
|
"6 Months": 180,
|
||||||
|
"1 Year": 365,
|
||||||
|
"2 Years": 730,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stocks_to_str(stocks):
|
||||||
|
return ",".join(stocks)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Data Loading
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# PRODUCTION PATTERN: Use parameterized queries for real stock data
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# For production use with actual stock tables, use parameterized queries:
|
||||||
|
#
|
||||||
|
# STOCK_QUERY = """
|
||||||
|
# SELECT trade_date AS date, ticker, close_price
|
||||||
|
# FROM stock_prices
|
||||||
|
# WHERE ticker = ANY(:tickers)
|
||||||
|
# AND trade_date >= DATEADD(day, -:days, CURRENT_DATE())
|
||||||
|
# ORDER BY trade_date, ticker
|
||||||
|
# """
|
||||||
|
#
|
||||||
|
# def load_stock_data(tickers: list[str], days: int) -> pd.DataFrame:
|
||||||
|
# conn = get_snowflake_connection()
|
||||||
|
# df = conn.query(
|
||||||
|
# STOCK_QUERY,
|
||||||
|
# params={"tickers": tickers, "days": days}
|
||||||
|
# )
|
||||||
|
# return df
|
||||||
|
#
|
||||||
|
# The synthetic data generation below uses f-strings for the VALUES clause
|
||||||
|
# which cannot be parameterized. This is acceptable for demo/synthetic data
|
||||||
|
# but should NOT be used with user input in production.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def generate_stock_data_query(tickers: list[str], days: int) -> str:
|
||||||
|
"""Generate SQL query that creates synthetic stock price data.
|
||||||
|
|
||||||
|
NOTE: This uses f-strings for VALUES clause construction which is acceptable
|
||||||
|
for synthetic data generation with controlled inputs. For production apps
|
||||||
|
with real tables, always use parameterized queries as shown above.
|
||||||
|
"""
|
||||||
|
# Build ticker values and base prices (controlled data, not user input)
|
||||||
|
ticker_values = []
|
||||||
|
for ticker in tickers:
|
||||||
|
base_price = STOCK_BASE_PRICES.get(ticker, 100 + hash(ticker) % 400)
|
||||||
|
growth_rate = 0.0003 + (hash(ticker) % 10) * 0.00005
|
||||||
|
volatility = 0.02 + (hash(ticker) % 5) * 0.005
|
||||||
|
ticker_values.append(f"('{ticker}', {base_price}, {growth_rate}, {volatility})")
|
||||||
|
|
||||||
|
tickers_cte = ", ".join(ticker_values)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
WITH tickers AS (
|
||||||
|
SELECT column1 AS ticker, column2 AS base_price, column3 AS growth_rate, column4 AS volatility
|
||||||
|
FROM VALUES {tickers_cte}
|
||||||
|
),
|
||||||
|
date_series AS (
|
||||||
|
SELECT DATEADD(day, -seq4(), CURRENT_DATE() - 1) AS trade_date
|
||||||
|
FROM TABLE(GENERATOR(ROWCOUNT => {days}))
|
||||||
|
),
|
||||||
|
raw_prices AS (
|
||||||
|
SELECT
|
||||||
|
d.trade_date,
|
||||||
|
t.ticker,
|
||||||
|
t.base_price * POWER(1 + t.growth_rate, DATEDIFF(day, DATEADD(day, -{days}, CURRENT_DATE()), d.trade_date))
|
||||||
|
* (1 + (RANDOM() / 10000000000000000000.0 - 0.5) * t.volatility * 2) AS close_price
|
||||||
|
FROM date_series d
|
||||||
|
CROSS JOIN tickers t
|
||||||
|
WHERE DAYOFWEEK(d.trade_date) NOT IN (0, 6) -- Exclude weekends
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
trade_date AS date,
|
||||||
|
ticker,
|
||||||
|
ROUND(close_price, 2) AS close_price
|
||||||
|
FROM raw_prices
|
||||||
|
ORDER BY trade_date, ticker
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=3600, show_spinner="Loading stock data from Snowflake...")
|
||||||
|
def load_stock_data(tickers: list[str], days: int) -> pd.DataFrame:
|
||||||
|
"""Load stock price data from Snowflake."""
|
||||||
|
conn = get_snowflake_connection()
|
||||||
|
query = generate_stock_data_query(tickers, days)
|
||||||
|
df = conn.query(query)
|
||||||
|
df.columns = df.columns.str.lower()
|
||||||
|
|
||||||
|
# Pivot to get tickers as columns
|
||||||
|
pivoted = df.pivot(index="date", columns="ticker", values="close_price")
|
||||||
|
pivoted.index = pd.to_datetime(pivoted.index)
|
||||||
|
return pivoted
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Session State and Query Params
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if "tickers_input" not in st.session_state:
|
||||||
|
st.session_state.tickers_input = st.query_params.get(
|
||||||
|
"stocks", stocks_to_str(DEFAULT_STOCKS)
|
||||||
|
).split(",")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page Layout
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Check Snowflake connection
|
||||||
|
get_snowflake_connection()
|
||||||
|
|
||||||
|
cols = st.columns([1, 3])
|
||||||
|
|
||||||
|
top_left_cell = cols[0].container(
|
||||||
|
border=True, height="stretch", vertical_alignment="center"
|
||||||
|
)
|
||||||
|
|
||||||
|
with top_left_cell:
|
||||||
|
# Selectbox for stock tickers
|
||||||
|
tickers = st.multiselect(
|
||||||
|
"Stock tickers",
|
||||||
|
options=sorted(set(STOCKS) | set(st.session_state.tickers_input)),
|
||||||
|
default=st.session_state.tickers_input,
|
||||||
|
placeholder="Choose stocks to compare. Example: NVDA",
|
||||||
|
accept_new_options=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time horizon selector
|
||||||
|
horizon = st.pills(
|
||||||
|
"Time horizon",
|
||||||
|
options=list(HORIZON_MAP.keys()),
|
||||||
|
default="6 Months",
|
||||||
|
)
|
||||||
|
|
||||||
|
tickers = [t.upper() for t in tickers]
|
||||||
|
|
||||||
|
# Update query param when text input changes
|
||||||
|
if tickers:
|
||||||
|
st.query_params["stocks"] = stocks_to_str(tickers)
|
||||||
|
else:
|
||||||
|
st.query_params.pop("stocks", None)
|
||||||
|
|
||||||
|
if not tickers:
|
||||||
|
top_left_cell.info("Pick some stocks to compare", icon=":material/info:")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
right_cell = cols[1].container(
|
||||||
|
border=True, height="stretch", vertical_alignment="center"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load the data from Snowflake
|
||||||
|
try:
|
||||||
|
data = load_stock_data(tickers, HORIZON_MAP[horizon])
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Error loading stock data: {e}")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
# Check for missing data
|
||||||
|
missing_tickers = [t for t in tickers if t not in data.columns]
|
||||||
|
if missing_tickers:
|
||||||
|
st.warning(f"No data available for: {', '.join(missing_tickers)}")
|
||||||
|
# Filter to available tickers
|
||||||
|
tickers = [t for t in tickers if t in data.columns]
|
||||||
|
if not tickers:
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
# Normalize prices (start at 1)
|
||||||
|
normalized = data[tickers].div(data[tickers].iloc[0])
|
||||||
|
|
||||||
|
latest_norm_values = {normalized[ticker].iat[-1]: ticker for ticker in tickers}
|
||||||
|
max_norm_value = max(latest_norm_values.items())
|
||||||
|
min_norm_value = min(latest_norm_values.items())
|
||||||
|
|
||||||
|
bottom_left_cell = cols[0].container(
|
||||||
|
border=True, height="stretch", vertical_alignment="center"
|
||||||
|
)
|
||||||
|
|
||||||
|
with bottom_left_cell:
|
||||||
|
metric_cols = st.columns(2)
|
||||||
|
metric_cols[0].metric(
|
||||||
|
"Best stock",
|
||||||
|
max_norm_value[1],
|
||||||
|
delta=f"{round((max_norm_value[0] - 1) * 100)}%",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
metric_cols[1].metric(
|
||||||
|
"Worst stock",
|
||||||
|
min_norm_value[1],
|
||||||
|
delta=f"{round((min_norm_value[0] - 1) * 100)}%",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plot normalized prices
|
||||||
|
with right_cell:
|
||||||
|
st.altair_chart(
|
||||||
|
alt.Chart(
|
||||||
|
normalized.reset_index().melt(
|
||||||
|
id_vars=["date"], var_name="Stock", value_name="Normalized price"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
alt.X("date:T", title="Date"),
|
||||||
|
alt.Y("Normalized price:Q").scale(zero=False),
|
||||||
|
alt.Color("Stock:N"),
|
||||||
|
)
|
||||||
|
.properties(height=400)
|
||||||
|
)
|
||||||
|
|
||||||
|
""
|
||||||
|
""
|
||||||
|
|
||||||
|
# Plot individual stock vs peer average
|
||||||
|
"""
|
||||||
|
## Individual stocks vs peer average
|
||||||
|
|
||||||
|
For the analysis below, the "peer average" when analyzing stock X always
|
||||||
|
excludes X itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(tickers) <= 1:
|
||||||
|
st.warning("Pick 2 or more tickers to compare them")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
NUM_COLS = 4
|
||||||
|
chart_cols = st.columns(NUM_COLS)
|
||||||
|
|
||||||
|
for i, ticker in enumerate(tickers):
|
||||||
|
# Calculate peer average (excluding current stock)
|
||||||
|
peers = normalized.drop(columns=[ticker])
|
||||||
|
peer_avg = peers.mean(axis=1)
|
||||||
|
|
||||||
|
# Create DataFrame with peer average
|
||||||
|
plot_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"Date": normalized.index,
|
||||||
|
ticker: normalized[ticker],
|
||||||
|
"Peer average": peer_avg,
|
||||||
|
}
|
||||||
|
).melt(id_vars=["Date"], var_name="Series", value_name="Price")
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(plot_data)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
alt.X("Date:T"),
|
||||||
|
alt.Y("Price:Q").scale(zero=False),
|
||||||
|
alt.Color(
|
||||||
|
"Series:N",
|
||||||
|
scale=alt.Scale(domain=[ticker, "Peer average"], range=["red", "gray"]),
|
||||||
|
legend=alt.Legend(orient="bottom"),
|
||||||
|
),
|
||||||
|
alt.Tooltip(["Date", "Series", "Price"]),
|
||||||
|
)
|
||||||
|
.properties(title=f"{ticker} vs peer average", height=300)
|
||||||
|
)
|
||||||
|
|
||||||
|
cell = chart_cols[(i * 2) % NUM_COLS].container(border=True)
|
||||||
|
cell.write("")
|
||||||
|
cell.altair_chart(chart)
|
||||||
|
|
||||||
|
# Create Delta chart
|
||||||
|
plot_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"Date": normalized.index,
|
||||||
|
"Delta": normalized[ticker] - peer_avg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(plot_data)
|
||||||
|
.mark_area()
|
||||||
|
.encode(
|
||||||
|
alt.X("Date:T"),
|
||||||
|
alt.Y("Delta:Q").scale(zero=False),
|
||||||
|
)
|
||||||
|
.properties(title=f"{ticker} minus peer average", height=300)
|
||||||
|
)
|
||||||
|
|
||||||
|
cell = chart_cols[(i * 2 + 1) % NUM_COLS].container(border=True)
|
||||||
|
cell.write("")
|
||||||
|
cell.altair_chart(chart)
|
||||||
|
|
||||||
|
""
|
||||||
|
""
|
||||||
|
|
||||||
|
"""
|
||||||
|
## Raw data
|
||||||
|
"""
|
||||||
|
|
||||||
|
st.caption(":material/cloud: Data loaded from Snowflake (synthetic)")
|
||||||
|
data[tickers]
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard-stock-peers"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Stock peer analysis dashboard: easily compare stocks against others in their peer group"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"altair>=5.5.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
"yfinance>=0.2.55",
|
||||||
|
]
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import yfinance as yf
|
||||||
|
import pandas as pd
|
||||||
|
import altair as alt
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="Stock peer analysis dashboard",
|
||||||
|
page_icon=":chart_with_upwards_trend:",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
# :material/query_stats: Stock peer analysis
|
||||||
|
|
||||||
|
Easily compare stocks against others in their peer group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
"" # Add some space.
|
||||||
|
|
||||||
|
cols = st.columns([1, 3])
|
||||||
|
# Will declare right cell later to avoid showing it when no data.
|
||||||
|
|
||||||
|
STOCKS = [
|
||||||
|
"AAPL",
|
||||||
|
"ABBV",
|
||||||
|
"ACN",
|
||||||
|
"ADBE",
|
||||||
|
"ADP",
|
||||||
|
"AMD",
|
||||||
|
"AMGN",
|
||||||
|
"AMT",
|
||||||
|
"AMZN",
|
||||||
|
"APD",
|
||||||
|
"AVGO",
|
||||||
|
"AXP",
|
||||||
|
"BA",
|
||||||
|
"BK",
|
||||||
|
"BKNG",
|
||||||
|
"BMY",
|
||||||
|
"BRK.B",
|
||||||
|
"BSX",
|
||||||
|
"C",
|
||||||
|
"CAT",
|
||||||
|
"CI",
|
||||||
|
"CL",
|
||||||
|
"CMCSA",
|
||||||
|
"COST",
|
||||||
|
"CRM",
|
||||||
|
"CSCO",
|
||||||
|
"CVX",
|
||||||
|
"DE",
|
||||||
|
"DHR",
|
||||||
|
"DIS",
|
||||||
|
"DUK",
|
||||||
|
"ELV",
|
||||||
|
"EOG",
|
||||||
|
"EQR",
|
||||||
|
"FDX",
|
||||||
|
"GD",
|
||||||
|
"GE",
|
||||||
|
"GILD",
|
||||||
|
"GOOG",
|
||||||
|
"GOOGL",
|
||||||
|
"HD",
|
||||||
|
"HON",
|
||||||
|
"HUM",
|
||||||
|
"IBM",
|
||||||
|
"ICE",
|
||||||
|
"INTC",
|
||||||
|
"ISRG",
|
||||||
|
"JNJ",
|
||||||
|
"JPM",
|
||||||
|
"KO",
|
||||||
|
"LIN",
|
||||||
|
"LLY",
|
||||||
|
"LMT",
|
||||||
|
"LOW",
|
||||||
|
"MA",
|
||||||
|
"MCD",
|
||||||
|
"MDLZ",
|
||||||
|
"META",
|
||||||
|
"MMC",
|
||||||
|
"MO",
|
||||||
|
"MRK",
|
||||||
|
"MSFT",
|
||||||
|
"NEE",
|
||||||
|
"NFLX",
|
||||||
|
"NKE",
|
||||||
|
"NOW",
|
||||||
|
"NVDA",
|
||||||
|
"ORCL",
|
||||||
|
"PEP",
|
||||||
|
"PFE",
|
||||||
|
"PG",
|
||||||
|
"PLD",
|
||||||
|
"PM",
|
||||||
|
"PSA",
|
||||||
|
"REGN",
|
||||||
|
"RTX",
|
||||||
|
"SBUX",
|
||||||
|
"SCHW",
|
||||||
|
"SLB",
|
||||||
|
"SO",
|
||||||
|
"SPGI",
|
||||||
|
"T",
|
||||||
|
"TJX",
|
||||||
|
"TMO",
|
||||||
|
"TSLA",
|
||||||
|
"TXN",
|
||||||
|
"UNH",
|
||||||
|
"UNP",
|
||||||
|
"UPS",
|
||||||
|
"V",
|
||||||
|
"VZ",
|
||||||
|
"WFC",
|
||||||
|
"WM",
|
||||||
|
"WMT",
|
||||||
|
"XOM",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_STOCKS = ["AAPL", "MSFT", "GOOGL", "NVDA", "AMZN", "TSLA", "META"]
|
||||||
|
|
||||||
|
|
||||||
|
def stocks_to_str(stocks):
|
||||||
|
return ",".join(stocks)
|
||||||
|
|
||||||
|
|
||||||
|
if "tickers_input" not in st.session_state:
|
||||||
|
st.session_state.tickers_input = st.query_params.get(
|
||||||
|
"stocks", stocks_to_str(DEFAULT_STOCKS)
|
||||||
|
).split(",")
|
||||||
|
|
||||||
|
|
||||||
|
# Callback to update query param when input changes
|
||||||
|
def update_query_param():
|
||||||
|
if st.session_state.tickers_input:
|
||||||
|
st.query_params["stocks"] = stocks_to_str(st.session_state.tickers_input)
|
||||||
|
else:
|
||||||
|
st.query_params.pop("stocks", None)
|
||||||
|
|
||||||
|
|
||||||
|
top_left_cell = cols[0].container(
|
||||||
|
border=True, height="stretch", vertical_alignment="center"
|
||||||
|
)
|
||||||
|
|
||||||
|
with top_left_cell:
|
||||||
|
# Selectbox for stock tickers
|
||||||
|
tickers = st.multiselect(
|
||||||
|
"Stock tickers",
|
||||||
|
options=sorted(set(STOCKS) | set(st.session_state.tickers_input)),
|
||||||
|
default=st.session_state.tickers_input,
|
||||||
|
placeholder="Choose stocks to compare. Example: NVDA",
|
||||||
|
accept_new_options=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time horizon selector
|
||||||
|
horizon_map = {
|
||||||
|
"1 Month": "1mo",
|
||||||
|
"3 Months": "3mo",
|
||||||
|
"6 Months": "6mo",
|
||||||
|
"1 Year": "1y",
|
||||||
|
"5 Years": "5y",
|
||||||
|
"10 Years": "10y",
|
||||||
|
"20 Years": "20y",
|
||||||
|
}
|
||||||
|
|
||||||
|
with top_left_cell:
|
||||||
|
# Buttons for picking time horizon
|
||||||
|
horizon = st.pills(
|
||||||
|
"Time horizon",
|
||||||
|
options=list(horizon_map.keys()),
|
||||||
|
default="6 Months",
|
||||||
|
)
|
||||||
|
|
||||||
|
tickers = [t.upper() for t in tickers]
|
||||||
|
|
||||||
|
# Update query param when text input changes
|
||||||
|
if tickers:
|
||||||
|
st.query_params["stocks"] = stocks_to_str(tickers)
|
||||||
|
else:
|
||||||
|
# Clear the param if input is empty
|
||||||
|
st.query_params.pop("stocks", None)
|
||||||
|
|
||||||
|
if not tickers:
|
||||||
|
top_left_cell.info("Pick some stocks to compare", icon=":material/info:")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
|
||||||
|
right_cell = cols[1].container(
|
||||||
|
border=True, height="stretch", vertical_alignment="center"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_resource(show_spinner=False, ttl="6h")
|
||||||
|
def load_data(tickers, period):
|
||||||
|
tickers_obj = yf.Tickers(tickers)
|
||||||
|
data = tickers_obj.history(period=period)
|
||||||
|
if data is None:
|
||||||
|
raise RuntimeError("YFinance returned no data.")
|
||||||
|
return data["Close"]
|
||||||
|
|
||||||
|
|
||||||
|
# Load the data
|
||||||
|
try:
|
||||||
|
data = load_data(tickers, horizon_map[horizon])
|
||||||
|
except yf.exceptions.YFRateLimitError as e:
|
||||||
|
st.warning("YFinance is rate-limiting us :(\nTry again later.")
|
||||||
|
load_data.clear() # Remove the bad cache entry.
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
empty_columns = data.columns[data.isna().all()].tolist()
|
||||||
|
|
||||||
|
if empty_columns:
|
||||||
|
st.error(f"Error loading data for the tickers: {', '.join(empty_columns)}.")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
# Normalize prices (start at 1)
|
||||||
|
normalized = data.div(data.iloc[0])
|
||||||
|
|
||||||
|
latest_norm_values = {normalized[ticker].iat[-1]: ticker for ticker in tickers}
|
||||||
|
max_norm_value = max(latest_norm_values.items())
|
||||||
|
min_norm_value = min(latest_norm_values.items())
|
||||||
|
|
||||||
|
bottom_left_cell = cols[0].container(
|
||||||
|
border=True, height="stretch", vertical_alignment="center"
|
||||||
|
)
|
||||||
|
|
||||||
|
with bottom_left_cell:
|
||||||
|
cols = st.columns(2)
|
||||||
|
cols[0].metric(
|
||||||
|
"Best stock",
|
||||||
|
max_norm_value[1],
|
||||||
|
delta=f"{round(max_norm_value[0] * 100)}%",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
cols[1].metric(
|
||||||
|
"Worst stock",
|
||||||
|
min_norm_value[1],
|
||||||
|
delta=f"{round(min_norm_value[0] * 100)}%",
|
||||||
|
width="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Plot normalized prices
|
||||||
|
with right_cell:
|
||||||
|
st.altair_chart(
|
||||||
|
alt.Chart(
|
||||||
|
normalized.reset_index().melt(
|
||||||
|
id_vars=["Date"], var_name="Stock", value_name="Normalized price"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
alt.X("Date:T"),
|
||||||
|
alt.Y("Normalized price:Q").scale(zero=False),
|
||||||
|
alt.Color("Stock:N"),
|
||||||
|
)
|
||||||
|
.properties(height=400)
|
||||||
|
)
|
||||||
|
|
||||||
|
""
|
||||||
|
""
|
||||||
|
|
||||||
|
# Plot individual stock vs peer average
|
||||||
|
"""
|
||||||
|
## Individual stocks vs peer average
|
||||||
|
|
||||||
|
For the analysis below, the "peer average" when analyzing stock X always
|
||||||
|
excludes X itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(tickers) <= 1:
|
||||||
|
st.warning("Pick 2 or more tickers to compare them")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
NUM_COLS = 4
|
||||||
|
cols = st.columns(NUM_COLS)
|
||||||
|
|
||||||
|
for i, ticker in enumerate(tickers):
|
||||||
|
# Calculate peer average (excluding current stock)
|
||||||
|
peers = normalized.drop(columns=[ticker])
|
||||||
|
peer_avg = peers.mean(axis=1)
|
||||||
|
|
||||||
|
# Create DataFrame with peer average.
|
||||||
|
plot_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"Date": normalized.index,
|
||||||
|
ticker: normalized[ticker],
|
||||||
|
"Peer average": peer_avg,
|
||||||
|
}
|
||||||
|
).melt(id_vars=["Date"], var_name="Series", value_name="Price")
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(plot_data)
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
alt.X("Date:T"),
|
||||||
|
alt.Y("Price:Q").scale(zero=False),
|
||||||
|
alt.Color(
|
||||||
|
"Series:N",
|
||||||
|
scale=alt.Scale(domain=[ticker, "Peer average"], range=["red", "gray"]),
|
||||||
|
legend=alt.Legend(orient="bottom"),
|
||||||
|
),
|
||||||
|
alt.Tooltip(["Date", "Series", "Price"]),
|
||||||
|
)
|
||||||
|
.properties(title=f"{ticker} vs peer average", height=300)
|
||||||
|
)
|
||||||
|
|
||||||
|
cell = cols[(i * 2) % NUM_COLS].container(border=True)
|
||||||
|
cell.write("")
|
||||||
|
cell.altair_chart(chart)
|
||||||
|
|
||||||
|
# Create Delta chart
|
||||||
|
plot_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"Date": normalized.index,
|
||||||
|
"Delta": normalized[ticker] - peer_avg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
chart = (
|
||||||
|
alt.Chart(plot_data)
|
||||||
|
.mark_area()
|
||||||
|
.encode(
|
||||||
|
alt.X("Date:T"),
|
||||||
|
alt.Y("Delta:Q").scale(zero=False),
|
||||||
|
)
|
||||||
|
.properties(title=f"{ticker} minus peer average", height=300)
|
||||||
|
)
|
||||||
|
|
||||||
|
cell = cols[(i * 2 + 1) % NUM_COLS].container(border=True)
|
||||||
|
cell.write("")
|
||||||
|
cell.altair_chart(chart)
|
||||||
|
|
||||||
|
""
|
||||||
|
""
|
||||||
|
|
||||||
|
"""
|
||||||
|
## Raw data
|
||||||
|
"""
|
||||||
|
|
||||||
|
data
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Streamlit theme templates
|
||||||
|
|
||||||
|
Ready-to-use theme templates for Streamlit apps.
|
||||||
|
|
||||||
|
## Available themes
|
||||||
|
|
||||||
|
| Theme | Base | Primary color | Fonts |
|
||||||
|
|-------|------|---------------|-------|
|
||||||
|
| **snowflake** | Light | `#29B5E8` (cyan) | Inter, JetBrains Mono |
|
||||||
|
| **dracula** | Dark | `#BD93F9` (purple) | Fira Sans, JetBrains Mono |
|
||||||
|
| **nord** | Dark | `#88C0D0` (frost blue) | Inter, JetBrains Mono |
|
||||||
|
| **stripe** | Light | `#635BFF` (indigo) | Inter, Source Code Pro |
|
||||||
|
| **solarized-light** | Light | `#268BD2` (blue) | Source Sans 3, Source Code Pro |
|
||||||
|
| **spotify** | Dark | `#1DB954` (green) | Inter, Fira Code |
|
||||||
|
| **github** | Light | `#0969DA` (blue) | Inter, JetBrains Mono |
|
||||||
|
| **minimal** | Dark | `#6366f1` (indigo) | Inter, JetBrains Mono |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run a theme locally
|
||||||
|
cd templates/themes/spotify
|
||||||
|
uv sync
|
||||||
|
uv run streamlit run streamlit_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploying to Snowflake
|
||||||
|
|
||||||
|
Before deploying, update `snowflake.yml` with your account-specific resources:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Find available compute pools
|
||||||
|
SHOW COMPUTE POOLS;
|
||||||
|
|
||||||
|
# Find available external access integrations
|
||||||
|
SHOW EXTERNAL ACCESS INTEGRATIONS;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then edit `snowflake.yml` to replace the placeholders:
|
||||||
|
- `<YOUR_COMPUTE_POOL>` → e.g., `STREAMLIT_DEDICATED_POOL`
|
||||||
|
- `<YOUR_PYPI_INTEGRATION>` → e.g., `PYPI_ACCESS_INTEGRATION`
|
||||||
|
- `<FROM_CONNECTION>` values are filled from your active connection
|
||||||
|
|
||||||
|
Deploy with:
|
||||||
|
```bash
|
||||||
|
snow streamlit deploy --replace
|
||||||
|
```
|
||||||
|
|
||||||
|
## How Streamlit theming works
|
||||||
|
|
||||||
|
A custom theme requires two things:
|
||||||
|
|
||||||
|
### 1. Theme configuration in `.streamlit/config.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
base = "dark" # "dark" or "light"
|
||||||
|
primaryColor = "#1DB954" # Buttons, links, highlights
|
||||||
|
backgroundColor = "#121212" # Main background
|
||||||
|
secondaryBackgroundColor = "#181818" # Sidebar, cards
|
||||||
|
textColor = "#FFFFFF" # Main text color
|
||||||
|
font = "Inter" # Body font
|
||||||
|
codeFont = "FiraCode" # Code blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. For Snowflake deployment: local font files
|
||||||
|
|
||||||
|
Snowflake doesn't allow remote URL fetches, so fonts must be bundled locally:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
enableStaticServing = true # Required for static files
|
||||||
|
|
||||||
|
[[theme.fontFaces]]
|
||||||
|
family = "Inter"
|
||||||
|
url = "app/static/Inter-Regular.ttf" # Note: app/ prefix required
|
||||||
|
weight = 400
|
||||||
|
|
||||||
|
[[theme.fontFaces]]
|
||||||
|
family = "Inter"
|
||||||
|
url = "app/static/Inter-Bold.ttf"
|
||||||
|
weight = 700
|
||||||
|
```
|
||||||
|
|
||||||
|
Font files go in `static/` directory and are referenced with `app/static/` prefix.
|
||||||
|
|
||||||
|
### Sidebar theming (optional)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#181818"
|
||||||
|
secondaryBackgroundColor = "#121212"
|
||||||
|
borderColor = "#282828"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme file structure
|
||||||
|
|
||||||
|
Each theme directory contains:
|
||||||
|
|
||||||
|
```
|
||||||
|
{theme}/
|
||||||
|
├── .streamlit/config.toml # Theme colors and fonts
|
||||||
|
├── streamlit_app.py # Demo app showing the theme
|
||||||
|
├── pyproject.toml # Dependencies
|
||||||
|
├── snowflake.yml # Snowflake deployment config
|
||||||
|
└── static/ # Bundled font files (*.ttf)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
All themes require Python >=3.11 and use:
|
||||||
|
- `snowflake-connector-python>=3.3.0` (required — `streamlit[snowflake]` silently skips this on Python 3.12+)
|
||||||
|
- `streamlit[snowflake]>=1.54.0`
|
||||||
|
- `altair>=5.5.0`
|
||||||
|
- `pandas>=2.2.3`
|
||||||
|
|
||||||
|
## Font licensing
|
||||||
|
|
||||||
|
All bundled fonts are licensed under the [SIL Open Font License 1.1](https://openfontlicense.org/), which permits free use, redistribution, and modification:
|
||||||
|
|
||||||
|
| Font | Used by | Source |
|
||||||
|
|------|---------|--------|
|
||||||
|
| Inter | snowflake, nord, spotify, github, minimal, stripe | [github.com/rsms/inter](https://github.com/rsms/inter) |
|
||||||
|
| JetBrains Mono | snowflake, dracula, nord, github, minimal | [github.com/JetBrains/JetBrainsMono](https://github.com/JetBrains/JetBrainsMono) |
|
||||||
|
| Fira Sans | dracula | [github.com/mozilla/Fira](https://github.com/mozilla/Fira) |
|
||||||
|
| Fira Code | spotify | [github.com/tonsky/FiraCode](https://github.com/tonsky/FiraCode) |
|
||||||
|
| Source Sans 3 | solarized-light | [github.com/adobe-fonts/source-sans](https://github.com/adobe-fonts/source-sans) |
|
||||||
|
| Source Code Pro | solarized-light, stripe | [github.com/adobe-fonts/source-code-pro](https://github.com/adobe-fonts/source-code-pro) |
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Dracula Theme for Streamlit
|
||||||
|
# Popular dark theme with vibrant colors on dark background
|
||||||
|
[theme]
|
||||||
|
base = "dark"
|
||||||
|
primaryColor = "#bd93f9"
|
||||||
|
backgroundColor = "#282a36"
|
||||||
|
secondaryBackgroundColor = "#21222c"
|
||||||
|
codeBackgroundColor = "#21222c"
|
||||||
|
textColor = "#f8f8f2"
|
||||||
|
linkColor = "#8be9fd"
|
||||||
|
borderColor = "#44475a"
|
||||||
|
showWidgetBorder = true
|
||||||
|
showSidebarBorder = true
|
||||||
|
baseRadius = "8px"
|
||||||
|
buttonRadius = "8px"
|
||||||
|
font = "'Fira Sans':https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;600;700&display=swap"
|
||||||
|
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "0.875rem"
|
||||||
|
codeTextColor = "#f8f8f2"
|
||||||
|
baseFontSize = 14
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [700, 600, 600, 600, 600, 600]
|
||||||
|
linkUnderline = false
|
||||||
|
chartCategoricalColors = ["#bd93f9", "#50fa7b", "#ff79c6", "#8be9fd", "#ffb86c", "#ff5555", "#f1fa8c"]
|
||||||
|
|
||||||
|
# Dracula color palette
|
||||||
|
violetColor = "#bd93f9"
|
||||||
|
greenColor = "#50fa7b"
|
||||||
|
redColor = "#ff5555"
|
||||||
|
blueColor = "#8be9fd"
|
||||||
|
yellowColor = "#f1fa8c"
|
||||||
|
orangeColor = "#ffb86c"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#21222c"
|
||||||
|
secondaryBackgroundColor = "#191a21"
|
||||||
|
codeBackgroundColor = "#191a21"
|
||||||
|
borderColor = "#44475a"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# GitHub Theme for Streamlit
|
||||||
|
# Clean, developer-friendly, functional with signature blue accents
|
||||||
|
[theme]
|
||||||
|
primaryColor = "#0969da"
|
||||||
|
backgroundColor = "#ffffff"
|
||||||
|
secondaryBackgroundColor = "#f6f8fa"
|
||||||
|
codeBackgroundColor = "#f6f8fa"
|
||||||
|
textColor = "#1F2328"
|
||||||
|
linkColor = "#0969da"
|
||||||
|
borderColor = "#d0d7de"
|
||||||
|
showWidgetBorder = true
|
||||||
|
showSidebarBorder = true
|
||||||
|
baseRadius = "6px"
|
||||||
|
buttonRadius = "6px"
|
||||||
|
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "0.85rem"
|
||||||
|
codeTextColor = "#1F2328"
|
||||||
|
baseFontSize = 14
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [600, 600, 600, 600, 600, 600]
|
||||||
|
linkUnderline = false
|
||||||
|
chartCategoricalColors = ["#0969da", "#1a7f37", "#bf3989", "#8250df", "#cf222e", "#bf8700", "#57606a"]
|
||||||
|
|
||||||
|
# GitHub color palette
|
||||||
|
blueColor = "#0969da"
|
||||||
|
greenColor = "#1a7f37"
|
||||||
|
redColor = "#cf222e"
|
||||||
|
violetColor = "#8250df"
|
||||||
|
orangeColor = "#bf8700"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#f6f8fa"
|
||||||
|
secondaryBackgroundColor = "#eaeef2"
|
||||||
|
codeBackgroundColor = "#eaeef2"
|
||||||
|
borderColor = "#d0d7de"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Minimal Dark Theme for Streamlit
|
||||||
|
# Clean, distraction-free dark theme with subtle accents
|
||||||
|
[theme]
|
||||||
|
base = "dark"
|
||||||
|
primaryColor = "#6366f1"
|
||||||
|
backgroundColor = "#18181b"
|
||||||
|
secondaryBackgroundColor = "#27272a"
|
||||||
|
codeBackgroundColor = "#27272a"
|
||||||
|
textColor = "#fafafa"
|
||||||
|
linkColor = "#818cf8"
|
||||||
|
borderColor = "#3f3f46"
|
||||||
|
showWidgetBorder = false
|
||||||
|
showSidebarBorder = false
|
||||||
|
baseRadius = "6px"
|
||||||
|
buttonRadius = "6px"
|
||||||
|
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "0.85rem"
|
||||||
|
codeTextColor = "#e4e4e7"
|
||||||
|
baseFontSize = 14
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [600, 600, 500, 500, 500, 500]
|
||||||
|
linkUnderline = false
|
||||||
|
chartCategoricalColors = ["#6366f1", "#8b5cf6", "#ec4899", "#14b8a6", "#f59e0b", "#ef4444", "#22c55e"]
|
||||||
|
|
||||||
|
# Color palette
|
||||||
|
violetColor = "#8b5cf6"
|
||||||
|
blueColor = "#6366f1"
|
||||||
|
greenColor = "#22c55e"
|
||||||
|
yellowColor = "#f59e0b"
|
||||||
|
orangeColor = "#f97316"
|
||||||
|
redColor = "#ef4444"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#09090b"
|
||||||
|
secondaryBackgroundColor = "#18181b"
|
||||||
|
codeBackgroundColor = "#18181b"
|
||||||
|
borderColor = "#27272a"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Nord Theme for Streamlit
|
||||||
|
# Arctic, north-bluish color palette with frost-inspired accents
|
||||||
|
[theme]
|
||||||
|
base = "dark"
|
||||||
|
primaryColor = "#88c0d0"
|
||||||
|
backgroundColor = "#2e3440"
|
||||||
|
secondaryBackgroundColor = "#3b4252"
|
||||||
|
codeBackgroundColor = "#3b4252"
|
||||||
|
textColor = "#eceff4"
|
||||||
|
linkColor = "#81a1c1"
|
||||||
|
borderColor = "#4c566a"
|
||||||
|
showWidgetBorder = true
|
||||||
|
showSidebarBorder = true
|
||||||
|
baseRadius = "4px"
|
||||||
|
buttonRadius = "4px"
|
||||||
|
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "0.875rem"
|
||||||
|
codeTextColor = "#d8dee9"
|
||||||
|
baseFontSize = 14
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [600, 600, 600, 600, 600, 600]
|
||||||
|
linkUnderline = false
|
||||||
|
chartCategoricalColors = ["#88c0d0", "#81a1c1", "#5e81ac", "#a3be8c", "#ebcb8b", "#d08770", "#bf616a"]
|
||||||
|
|
||||||
|
# Nord color palette (Frost + Aurora)
|
||||||
|
blueColor = "#81a1c1"
|
||||||
|
greenColor = "#a3be8c"
|
||||||
|
yellowColor = "#ebcb8b"
|
||||||
|
orangeColor = "#d08770"
|
||||||
|
redColor = "#bf616a"
|
||||||
|
violetColor = "#b48ead"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#3b4252"
|
||||||
|
secondaryBackgroundColor = "#434c5e"
|
||||||
|
codeBackgroundColor = "#434c5e"
|
||||||
|
borderColor = "#4c566a"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Snowflake Theme for Streamlit
|
||||||
|
# The Data Cloud company aesthetic - clean, professional, icy blue branding
|
||||||
|
[theme]
|
||||||
|
primaryColor = "#29B5E8"
|
||||||
|
backgroundColor = "#ffffff"
|
||||||
|
secondaryBackgroundColor = "#f4f9fc"
|
||||||
|
codeBackgroundColor = "#e8f4f8"
|
||||||
|
textColor = "#11567F"
|
||||||
|
linkColor = "#29B5E8"
|
||||||
|
borderColor = "#d0e8f2"
|
||||||
|
showWidgetBorder = true
|
||||||
|
showSidebarBorder = true
|
||||||
|
baseRadius = "8px"
|
||||||
|
buttonRadius = "8px"
|
||||||
|
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "13px"
|
||||||
|
codeTextColor = "#11567F"
|
||||||
|
baseFontSize = 14
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [600, 600, 600, 500, 500, 500]
|
||||||
|
linkUnderline = false
|
||||||
|
chartCategoricalColors = ["#29B5E8", "#FF8B00", "#36B37E", "#6554C0", "#DE350B", "#11567F", "#FFAB00", "#00A3BF"]
|
||||||
|
|
||||||
|
# Snowflake color palette
|
||||||
|
blueColor = "#29B5E8"
|
||||||
|
greenColor = "#36B37E"
|
||||||
|
yellowColor = "#FFAB00"
|
||||||
|
orangeColor = "#FF8B00"
|
||||||
|
redColor = "#DE350B"
|
||||||
|
violetColor = "#6554C0"
|
||||||
|
|
||||||
|
dataframeBorderColor = "#d0e8f2"
|
||||||
|
dataframeHeaderBackgroundColor = "#e8f4f8"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#11567F"
|
||||||
|
secondaryBackgroundColor = "#174D6A"
|
||||||
|
codeBackgroundColor = "#0E4D6B"
|
||||||
|
textColor = "#ffffff"
|
||||||
|
borderColor = "#1E6D94"
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Solarized Light Theme for Streamlit
|
||||||
|
# Precision colors designed for readability and reduced eye strain
|
||||||
|
[theme]
|
||||||
|
primaryColor = "#268bd2"
|
||||||
|
backgroundColor = "#fdf6e3"
|
||||||
|
secondaryBackgroundColor = "#eee8d5"
|
||||||
|
codeBackgroundColor = "#eee8d5"
|
||||||
|
textColor = "#657b83"
|
||||||
|
linkColor = "#268bd2"
|
||||||
|
borderColor = "#93a1a1"
|
||||||
|
showWidgetBorder = true
|
||||||
|
showSidebarBorder = true
|
||||||
|
baseRadius = "4px"
|
||||||
|
buttonRadius = "4px"
|
||||||
|
font = "'Source Sans 3':https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;500;600;700&display=swap"
|
||||||
|
codeFont = "'Source Code Pro':https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "0.875rem"
|
||||||
|
codeTextColor = "#586e75"
|
||||||
|
baseFontSize = 14
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
|
||||||
|
headingFontWeights = [600, 600, 600, 600, 600, 600]
|
||||||
|
linkUnderline = false
|
||||||
|
chartCategoricalColors = ["#268bd2", "#2aa198", "#859900", "#b58900", "#cb4b16", "#dc322f", "#d33682"]
|
||||||
|
|
||||||
|
# Solarized color palette
|
||||||
|
blueColor = "#268bd2"
|
||||||
|
greenColor = "#859900"
|
||||||
|
yellowColor = "#b58900"
|
||||||
|
orangeColor = "#cb4b16"
|
||||||
|
redColor = "#dc322f"
|
||||||
|
violetColor = "#6c71c4"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#eee8d5"
|
||||||
|
secondaryBackgroundColor = "#fdf6e3"
|
||||||
|
codeBackgroundColor = "#fdf6e3"
|
||||||
|
borderColor = "#93a1a1"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Spotify Theme for Streamlit
|
||||||
|
# Bold, energetic, high contrast with signature green
|
||||||
|
[theme]
|
||||||
|
base = "dark"
|
||||||
|
primaryColor = "#1DB954"
|
||||||
|
backgroundColor = "#191414"
|
||||||
|
secondaryBackgroundColor = "#282828"
|
||||||
|
codeBackgroundColor = "#282828"
|
||||||
|
textColor = "#ffffff"
|
||||||
|
linkColor = "#1DB954"
|
||||||
|
borderColor = "#404040"
|
||||||
|
showWidgetBorder = false
|
||||||
|
showSidebarBorder = false
|
||||||
|
baseRadius = "8px"
|
||||||
|
buttonRadius = "full"
|
||||||
|
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
codeFont = "'Fira Code':https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "0.85rem"
|
||||||
|
baseFontSize = 16
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontWeights = [800, 700, 700, 600, 600, 600]
|
||||||
|
headingFontSizes = ["48px", "36px", "28px", "22px", "18px", "16px"]
|
||||||
|
chartCategoricalColors = ["#1DB954", "#1ED760", "#B3B3B3", "#535353", "#191414", "#FFFFFF", "#509BF5"]
|
||||||
|
|
||||||
|
# Spotify color palette
|
||||||
|
greenColor = "#1DB954"
|
||||||
|
blueColor = "#509BF5"
|
||||||
|
grayColor = "#B3B3B3"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#000000"
|
||||||
|
secondaryBackgroundColor = "#282828"
|
||||||
|
codeBackgroundColor = "#282828"
|
||||||
|
borderColor = "#333333"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Stripe Theme for Streamlit
|
||||||
|
# Polished, professional, modern with signature purple/indigo gradients
|
||||||
|
[theme]
|
||||||
|
primaryColor = "#635bff"
|
||||||
|
backgroundColor = "#ffffff"
|
||||||
|
secondaryBackgroundColor = "#f6f9fc"
|
||||||
|
codeBackgroundColor = "#f7f9fc"
|
||||||
|
textColor = "#425466"
|
||||||
|
linkColor = "#635bff"
|
||||||
|
borderColor = "#e3e8ee"
|
||||||
|
showWidgetBorder = true
|
||||||
|
showSidebarBorder = true
|
||||||
|
baseRadius = "8px"
|
||||||
|
buttonRadius = "8px"
|
||||||
|
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
codeFont = "'Source Code Pro':https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500&display=swap"
|
||||||
|
codeFontSize = "0.85rem"
|
||||||
|
codeTextColor = "#425466"
|
||||||
|
baseFontSize = 15
|
||||||
|
baseFontWeight = 400
|
||||||
|
headingFontSizes = ["40px", "32px", "24px", "20px", "16px", "14px"]
|
||||||
|
headingFontWeights = [600, 600, 600, 600, 600, 600]
|
||||||
|
linkUnderline = false
|
||||||
|
chartCategoricalColors = ["#635bff", "#00d4ff", "#0a2540", "#adbdcc", "#80e9ff", "#7a73ff", "#425466"]
|
||||||
|
|
||||||
|
# Stripe color palette
|
||||||
|
violetColor = "#635bff"
|
||||||
|
blueColor = "#00d4ff"
|
||||||
|
grayColor = "#adbdcc"
|
||||||
|
|
||||||
|
[theme.sidebar]
|
||||||
|
backgroundColor = "#f6f9fc"
|
||||||
|
secondaryBackgroundColor = "#ebeef1"
|
||||||
|
codeBackgroundColor = "#ebeef1"
|
||||||
|
borderColor = "#e3e8ee"
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **{{title}}**")
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# DO NOT EDIT — managed by manage.py, edit _templates/pyproject.toml.tmpl instead
|
||||||
|
[project]
|
||||||
|
name = "theme-{{slug}}"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "{{title}} theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"snowflake-connector-python>=3.3.0",
|
||||||
|
"streamlit[snowflake]>=1.54.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "theme-dracula"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Dracula theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **Dracula**")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "theme-github"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "GitHub theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **GitHub**")
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Manage theme template directories (fully generated from _configs/).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py sync # Regenerate all theme directories
|
||||||
|
python manage.py check # Verify generated files haven't drifted
|
||||||
|
python manage.py new NAME # Scaffold a new theme config
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
SHARED = ROOT / "_shared"
|
||||||
|
TEMPLATES = ROOT / "_templates"
|
||||||
|
CONFIGS = ROOT / "_configs"
|
||||||
|
FONTS = SHARED / "fonts"
|
||||||
|
|
||||||
|
MANAGED_HEADER_PY = (
|
||||||
|
"# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead\n"
|
||||||
|
)
|
||||||
|
MANAGED_HEADER_TOML = (
|
||||||
|
"# DO NOT EDIT — managed by manage.py, edit _configs/{slug}.toml instead\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
GITATTR_START = "# BEGIN managed by manage.py"
|
||||||
|
GITATTR_END = "# END managed by manage.py"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Theme discovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TITLE_OVERRIDES = {"github": "GitHub"}
|
||||||
|
|
||||||
|
|
||||||
|
def slug_to_title(slug):
|
||||||
|
"""Derive a display title from a directory slug: 'solarized-light' -> 'Solarized Light'."""
|
||||||
|
if slug in TITLE_OVERRIDES:
|
||||||
|
return TITLE_OVERRIDES[slug]
|
||||||
|
return " ".join(w.capitalize() for w in slug.split("-"))
|
||||||
|
|
||||||
|
|
||||||
|
def discover_themes():
|
||||||
|
"""Find themes by scanning _configs/*.toml."""
|
||||||
|
return [
|
||||||
|
{"slug": c.stem, "title": slug_to_title(c.stem)}
|
||||||
|
for c in sorted(CONFIGS.glob("*.toml"))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font discovery from config content
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def discover_fonts(config_text):
|
||||||
|
"""Extract font filenames referenced in config.toml content."""
|
||||||
|
return re.findall(r'url\s*=\s*["\']app/static/([^"\']+\.(?:ttf|otf|woff2?))["\']', config_text)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Content builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def expected_app(title):
|
||||||
|
"""Build expected streamlit_app.py content for a theme."""
|
||||||
|
source = (SHARED / "streamlit_app.py").read_text()
|
||||||
|
body = source.replace("{{title}}", title)
|
||||||
|
# Insert managed header after the module docstring
|
||||||
|
tree = ast.parse(body)
|
||||||
|
if ast.get_docstring(tree) is not None:
|
||||||
|
# The docstring is the first statement; find end of its line
|
||||||
|
docstring_node = tree.body[0]
|
||||||
|
end_line = docstring_node.end_lineno # 1-indexed
|
||||||
|
lines = body.split("\n")
|
||||||
|
insert_pos = end_line
|
||||||
|
return "\n".join(lines[:insert_pos]) + "\n" + MANAGED_HEADER_PY + "\n".join(lines[insert_pos:])
|
||||||
|
return MANAGED_HEADER_PY + body
|
||||||
|
|
||||||
|
|
||||||
|
def expected_config(slug):
|
||||||
|
"""Build expected .streamlit/config.toml content for a theme."""
|
||||||
|
source = (CONFIGS / f"{slug}.toml").read_text()
|
||||||
|
header = MANAGED_HEADER_TOML.replace("{slug}", slug)
|
||||||
|
return header + source
|
||||||
|
|
||||||
|
|
||||||
|
def expected_from_template(tmpl_path, replacements):
|
||||||
|
"""Build expected file content from a .tmpl template."""
|
||||||
|
text = tmpl_path.read_text()
|
||||||
|
for key, value in replacements.items():
|
||||||
|
text = text.replace("{{" + key + "}}", value)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sync
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sync_theme(theme):
|
||||||
|
"""Regenerate all files for a single theme directory."""
|
||||||
|
slug = theme["slug"]
|
||||||
|
title = theme["title"]
|
||||||
|
identifier = slug.replace("-", "_")
|
||||||
|
theme_dir = ROOT / slug
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
theme_dir.mkdir(exist_ok=True)
|
||||||
|
(theme_dir / ".streamlit").mkdir(exist_ok=True)
|
||||||
|
(theme_dir / "static").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# .streamlit/config.toml — from _configs/
|
||||||
|
(theme_dir / ".streamlit" / "config.toml").write_text(expected_config(slug))
|
||||||
|
|
||||||
|
# streamlit_app.py
|
||||||
|
(theme_dir / "streamlit_app.py").write_text(expected_app(title))
|
||||||
|
|
||||||
|
# pyproject.toml
|
||||||
|
(theme_dir / "pyproject.toml").write_text(
|
||||||
|
expected_from_template(
|
||||||
|
TEMPLATES / "pyproject.toml.tmpl",
|
||||||
|
{"slug": slug, "title": title},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# snowflake.yml
|
||||||
|
(theme_dir / "snowflake.yml").write_text(
|
||||||
|
expected_from_template(
|
||||||
|
TEMPLATES / "snowflake.yml.tmpl",
|
||||||
|
{"slug": slug, "title": title, "identifier": identifier},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fonts — copy from _shared/fonts/ based on config references
|
||||||
|
config_text = (CONFIGS / f"{slug}.toml").read_text()
|
||||||
|
font_names = discover_fonts(config_text)
|
||||||
|
static_dir = theme_dir / "static"
|
||||||
|
for fname in font_names:
|
||||||
|
src = FONTS / fname
|
||||||
|
if not src.exists():
|
||||||
|
print(f" Warning: font {fname} referenced in _configs/{slug}.toml not found in _shared/fonts/", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
shutil.copy2(src, static_dir / fname)
|
||||||
|
|
||||||
|
|
||||||
|
def update_gitattributes():
|
||||||
|
"""Update .gitattributes with entries for generated theme files."""
|
||||||
|
gitattr_path = ROOT / ".gitattributes"
|
||||||
|
|
||||||
|
new_section = "\n".join([
|
||||||
|
GITATTR_START,
|
||||||
|
"*/.streamlit/config.toml linguist-generated",
|
||||||
|
"*/streamlit_app.py linguist-generated",
|
||||||
|
"*/pyproject.toml linguist-generated",
|
||||||
|
"*/snowflake.yml linguist-generated",
|
||||||
|
"*/static/*.ttf linguist-generated",
|
||||||
|
GITATTR_END,
|
||||||
|
])
|
||||||
|
|
||||||
|
if gitattr_path.exists():
|
||||||
|
content = gitattr_path.read_text()
|
||||||
|
if GITATTR_START in content:
|
||||||
|
start = content.index(GITATTR_START)
|
||||||
|
end = content.index(GITATTR_END) + len(GITATTR_END)
|
||||||
|
content = content[:start] + new_section + content[end:]
|
||||||
|
else:
|
||||||
|
content = content.rstrip() + "\n\n" + new_section + "\n"
|
||||||
|
else:
|
||||||
|
content = new_section + "\n"
|
||||||
|
|
||||||
|
gitattr_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sync():
|
||||||
|
themes = discover_themes()
|
||||||
|
for t in themes:
|
||||||
|
sync_theme(t)
|
||||||
|
print(f" Synced {t['slug']}/")
|
||||||
|
|
||||||
|
# Remove orphaned theme directories (directories not matching any config)
|
||||||
|
config_slugs = {t["slug"] for t in themes}
|
||||||
|
orphans = [
|
||||||
|
d for d in sorted(ROOT.iterdir())
|
||||||
|
if d.is_dir() and not d.name.startswith("_") and d.name not in config_slugs
|
||||||
|
]
|
||||||
|
if orphans:
|
||||||
|
print("\nOrphaned directories (no matching config):")
|
||||||
|
for d in orphans:
|
||||||
|
print(f" {d.name}/")
|
||||||
|
answer = input("Remove these directories? [y/N] ").strip().lower()
|
||||||
|
if answer == "y":
|
||||||
|
for d in orphans:
|
||||||
|
shutil.rmtree(d)
|
||||||
|
print(f" Removed {d.name}/")
|
||||||
|
else:
|
||||||
|
print(" Skipped orphan removal.")
|
||||||
|
|
||||||
|
update_gitattributes()
|
||||||
|
print(f"\nSynced {len(themes)} theme directories.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_check():
|
||||||
|
themes = discover_themes()
|
||||||
|
drifted = []
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
for theme in themes:
|
||||||
|
slug = theme["slug"]
|
||||||
|
title = theme["title"]
|
||||||
|
identifier = slug.replace("-", "_")
|
||||||
|
theme_dir = ROOT / slug
|
||||||
|
|
||||||
|
# .streamlit/config.toml
|
||||||
|
target = theme_dir / ".streamlit" / "config.toml"
|
||||||
|
expected = expected_config(slug)
|
||||||
|
if not target.exists():
|
||||||
|
missing.append(f"{slug}/.streamlit/config.toml")
|
||||||
|
elif target.read_text() != expected:
|
||||||
|
drifted.append(f"{slug}/.streamlit/config.toml")
|
||||||
|
|
||||||
|
# streamlit_app.py
|
||||||
|
target = theme_dir / "streamlit_app.py"
|
||||||
|
if not target.exists():
|
||||||
|
missing.append(f"{slug}/streamlit_app.py")
|
||||||
|
elif target.read_text() != expected_app(title):
|
||||||
|
drifted.append(f"{slug}/streamlit_app.py")
|
||||||
|
|
||||||
|
# pyproject.toml
|
||||||
|
target = theme_dir / "pyproject.toml"
|
||||||
|
expected = expected_from_template(
|
||||||
|
TEMPLATES / "pyproject.toml.tmpl",
|
||||||
|
{"slug": slug, "title": title},
|
||||||
|
)
|
||||||
|
if not target.exists():
|
||||||
|
missing.append(f"{slug}/pyproject.toml")
|
||||||
|
elif target.read_text() != expected:
|
||||||
|
drifted.append(f"{slug}/pyproject.toml")
|
||||||
|
|
||||||
|
# snowflake.yml
|
||||||
|
target = theme_dir / "snowflake.yml"
|
||||||
|
expected = expected_from_template(
|
||||||
|
TEMPLATES / "snowflake.yml.tmpl",
|
||||||
|
{"slug": slug, "title": title, "identifier": identifier},
|
||||||
|
)
|
||||||
|
if not target.exists():
|
||||||
|
missing.append(f"{slug}/snowflake.yml")
|
||||||
|
elif target.read_text() != expected:
|
||||||
|
drifted.append(f"{slug}/snowflake.yml")
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
config_text = (CONFIGS / f"{slug}.toml").read_text()
|
||||||
|
font_names = discover_fonts(config_text)
|
||||||
|
for fname in font_names:
|
||||||
|
src = FONTS / fname
|
||||||
|
dest = theme_dir / "static" / fname
|
||||||
|
if not dest.exists():
|
||||||
|
missing.append(f"{slug}/static/{fname}")
|
||||||
|
elif src.exists() and src.read_bytes() != dest.read_bytes():
|
||||||
|
drifted.append(f"{slug}/static/{fname}")
|
||||||
|
|
||||||
|
ok = True
|
||||||
|
if missing:
|
||||||
|
print("Missing files (run 'python manage.py sync' to fix):")
|
||||||
|
for f in missing:
|
||||||
|
print(f" {f}")
|
||||||
|
ok = False
|
||||||
|
if drifted:
|
||||||
|
print("Drifted files (run 'python manage.py sync' to fix):")
|
||||||
|
for f in drifted:
|
||||||
|
print(f" {f}")
|
||||||
|
ok = False
|
||||||
|
if ok:
|
||||||
|
print(f"All generated files in sync across {len(themes)} theme directories.")
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# New
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_new(name):
|
||||||
|
config_path = CONFIGS / f"{name}.toml"
|
||||||
|
if config_path.exists():
|
||||||
|
print(f"Error: _configs/{name}.toml already exists", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
config_path.write_text(
|
||||||
|
f"[server]\nenableStaticServing = true\n\n"
|
||||||
|
f"# {name} theme\n[theme]\nbase = \"dark\"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Created _configs/{name}.toml — edit it, then run 'python manage.py sync'")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
||||||
|
print(__doc__.strip())
|
||||||
|
sys.exit(0 if len(sys.argv) > 1 else 1)
|
||||||
|
|
||||||
|
if not SHARED.is_dir():
|
||||||
|
print(f"Error: {SHARED} not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not CONFIGS.is_dir():
|
||||||
|
print(f"Error: {CONFIGS} not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "sync":
|
||||||
|
cmd_sync()
|
||||||
|
elif cmd == "new":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: python manage.py new NAME", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_new(sys.argv[2])
|
||||||
|
elif cmd == "check":
|
||||||
|
cmd_check()
|
||||||
|
else:
|
||||||
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "theme-minimal"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Minimal theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **Minimal**")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "theme-nord"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Nord theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **Nord**")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "theme-snowflake"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Snowflake theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **Snowflake**")
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "theme-solarized-light"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Solarized Light theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
]
|
||||||
+337
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **Solarized Light**")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "theme-spotify"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Spotify theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **Spotify**")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "theme-stripe"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Stripe theme for Streamlit"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"streamlit>=1.53.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Streamlit Element Explorer - Theme Demo
|
||||||
|
|
||||||
|
A comprehensive single-page app showcasing all major Streamlit components
|
||||||
|
with custom theming. Use this to preview how your theme looks across
|
||||||
|
different element types.
|
||||||
|
"""
|
||||||
|
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
|
||||||
|
|
||||||
|
# Initialize sample data in session state
|
||||||
|
if "chart_data" not in st.session_state:
|
||||||
|
np.random.seed(42)
|
||||||
|
st.session_state.chart_data = pd.DataFrame(
|
||||||
|
np.random.randn(20, 3), columns=["a", "b", "c"]
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_data = st.session_state.chart_data
|
||||||
|
|
||||||
|
st.title("Streamlit Element Explorer")
|
||||||
|
st.markdown(
|
||||||
|
"Explore how Streamlit's built-in elements look with this theme. "
|
||||||
|
"Select a category below to preview different components."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigation using segmented_control for better performance
|
||||||
|
section = st.segmented_control(
|
||||||
|
"Section",
|
||||||
|
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
|
||||||
|
default="Widgets",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WIDGETS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if section == "Widgets":
|
||||||
|
st.header("Widgets")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
st.subheader("Buttons")
|
||||||
|
cols = st.columns(4)
|
||||||
|
cols[0].button("Primary", type="primary")
|
||||||
|
cols[1].button("Secondary", type="secondary")
|
||||||
|
cols[2].button("Tertiary", type="tertiary")
|
||||||
|
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
|
||||||
|
|
||||||
|
# Form
|
||||||
|
with st.form(key="demo_form"):
|
||||||
|
st.subheader("Form")
|
||||||
|
form_cols = st.columns(2)
|
||||||
|
form_cols[0].text_input("Name", placeholder="Enter your name")
|
||||||
|
form_cols[1].text_input("Email", placeholder="you@example.com")
|
||||||
|
st.form_submit_button("Submit", type="primary")
|
||||||
|
|
||||||
|
# Selection widgets
|
||||||
|
st.subheader("Selection Widgets")
|
||||||
|
sel_cols = st.columns(2)
|
||||||
|
|
||||||
|
with sel_cols[0]:
|
||||||
|
st.checkbox("Checkbox option")
|
||||||
|
st.toggle("Toggle switch")
|
||||||
|
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
|
||||||
|
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
|
||||||
|
|
||||||
|
with sel_cols[1]:
|
||||||
|
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
|
||||||
|
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
|
||||||
|
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
|
||||||
|
st.caption("Feedback widget")
|
||||||
|
st.feedback("stars")
|
||||||
|
|
||||||
|
# Numeric & Sliders
|
||||||
|
st.subheader("Numeric Inputs")
|
||||||
|
num_cols = st.columns(3)
|
||||||
|
num_cols[0].number_input("Number input", value=42)
|
||||||
|
num_cols[1].slider("Slider", 0, 100, 50)
|
||||||
|
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
st.subheader("Date & Time")
|
||||||
|
dt_cols = st.columns(2)
|
||||||
|
dt_cols[0].date_input("Date input")
|
||||||
|
dt_cols[1].time_input("Time input")
|
||||||
|
|
||||||
|
# Text inputs
|
||||||
|
st.subheader("Text Inputs")
|
||||||
|
txt_cols = st.columns(2)
|
||||||
|
txt_cols[0].text_input("Text input", placeholder="Type something...")
|
||||||
|
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
st.subheader("File Upload")
|
||||||
|
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATA SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Data":
|
||||||
|
st.header("Data Display")
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
st.subheader("Metrics")
|
||||||
|
m_cols = st.columns(4)
|
||||||
|
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
|
||||||
|
m_cols[1].metric("Users", "2,847", "+8.2%")
|
||||||
|
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
|
||||||
|
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Dataframe
|
||||||
|
st.subheader("Dataframe")
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
|
||||||
|
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
|
||||||
|
"Salary": [95000, 78000, 82000, 105000, 71000],
|
||||||
|
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
|
||||||
|
"Active": [True, True, False, True, True],
|
||||||
|
})
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Salary": st.column_config.NumberColumn(format="$%d"),
|
||||||
|
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
|
||||||
|
"Active": st.column_config.CheckboxColumn("Active?"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
st.subheader("Static Table")
|
||||||
|
st.table(chart_data.head(5))
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
st.subheader("JSON Display")
|
||||||
|
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHARTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Charts":
|
||||||
|
st.header("Charts")
|
||||||
|
|
||||||
|
chart_cols = st.columns(2)
|
||||||
|
|
||||||
|
with chart_cols[0]:
|
||||||
|
st.subheader("Line Chart")
|
||||||
|
st.line_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Bar Chart")
|
||||||
|
st.bar_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
with chart_cols[1]:
|
||||||
|
st.subheader("Area Chart")
|
||||||
|
st.area_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
st.subheader("Scatter Chart")
|
||||||
|
st.scatter_chart(chart_data, height=250)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TEXT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Text":
|
||||||
|
st.header("Text Elements")
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
st.subheader("Headers")
|
||||||
|
st.title("Title Element")
|
||||||
|
st.header("Header Element")
|
||||||
|
st.subheader("Subheader Element")
|
||||||
|
st.caption("Caption text - smaller, muted")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
st.subheader("Markdown Formatting")
|
||||||
|
st.markdown(
|
||||||
|
"**Bold text**, *italic text*, ~~strikethrough~~, "
|
||||||
|
"`inline code`, [link](https://streamlit.io)"
|
||||||
|
)
|
||||||
|
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
|
||||||
|
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
|
||||||
|
|
||||||
|
# Colored text
|
||||||
|
st.subheader("Colored Text")
|
||||||
|
color_cols = st.columns(3)
|
||||||
|
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
|
||||||
|
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
|
||||||
|
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
st.subheader("Code Block")
|
||||||
|
st.code(
|
||||||
|
'''import streamlit as st
|
||||||
|
|
||||||
|
# Create a themed dashboard
|
||||||
|
st.set_page_config(page_title="My App", layout="wide")
|
||||||
|
st.title("Hello, Streamlit!")
|
||||||
|
|
||||||
|
# Display metrics
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("Users", "1,234", "+5%")
|
||||||
|
col2.metric("Revenue", "$56K", "+12%")''',
|
||||||
|
language="python",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LAYOUTS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Layouts":
|
||||||
|
st.header("Layout Elements")
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
st.subheader("Columns with Borders")
|
||||||
|
layout_cols = st.columns(3, border=True)
|
||||||
|
layout_cols[0].write("**Column 1**\n\nFirst column content")
|
||||||
|
layout_cols[1].write("**Column 2**\n\nSecond column content")
|
||||||
|
layout_cols[2].write("**Column 3**\n\nThird column content")
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
st.subheader("Tabs")
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
|
||||||
|
with tab1:
|
||||||
|
st.write("Chart tab content")
|
||||||
|
st.line_chart(chart_data["a"], height=150)
|
||||||
|
with tab2:
|
||||||
|
st.write("Data tab content")
|
||||||
|
st.dataframe(chart_data.head(3))
|
||||||
|
with tab3:
|
||||||
|
st.write("Settings tab content")
|
||||||
|
st.checkbox("Enable feature X")
|
||||||
|
st.checkbox("Enable feature Y", value=True)
|
||||||
|
|
||||||
|
# Expander
|
||||||
|
st.subheader("Expander")
|
||||||
|
with st.expander("Click to expand"):
|
||||||
|
st.write("This content is hidden by default.")
|
||||||
|
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
|
||||||
|
|
||||||
|
# Popover
|
||||||
|
st.subheader("Popover")
|
||||||
|
pop_cols = st.columns(3)
|
||||||
|
with pop_cols[0].popover("Open popover", icon=":material/info:"):
|
||||||
|
st.write("Popover content here!")
|
||||||
|
st.slider("Popover slider", 0, 100, 50)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
st.subheader("Container with Border")
|
||||||
|
with st.container(border=True):
|
||||||
|
st.write("**Bordered Container**")
|
||||||
|
st.write("Content inside a container with a visible border.")
|
||||||
|
st.button("Button inside container")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CHAT SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Chat":
|
||||||
|
st.header("Chat Elements")
|
||||||
|
|
||||||
|
# Chat messages
|
||||||
|
st.subheader("Chat Messages")
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Hello! How can I analyze my sales data?")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("I can help you with that! Here are a few options:")
|
||||||
|
st.markdown("""
|
||||||
|
1. **Revenue trends** - View monthly/quarterly patterns
|
||||||
|
2. **Top products** - Identify best sellers
|
||||||
|
3. **Customer segments** - Analyze by region or category
|
||||||
|
""")
|
||||||
|
|
||||||
|
with st.chat_message("user"):
|
||||||
|
st.write("Show me the revenue trends please.")
|
||||||
|
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.write("Here's your revenue trend for the past 20 periods:")
|
||||||
|
st.line_chart(chart_data["a"], height=200)
|
||||||
|
|
||||||
|
# Chat input
|
||||||
|
st.chat_input("Type a message...")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# STATUS SECTION
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
elif section == "Status":
|
||||||
|
st.header("Status Elements")
|
||||||
|
|
||||||
|
# Alert messages
|
||||||
|
st.subheader("Alert Messages")
|
||||||
|
st.error("Error: Something went wrong with the data pipeline.")
|
||||||
|
st.warning("Warning: API rate limit approaching (80% used).")
|
||||||
|
st.info("Info: New features available in the latest release.")
|
||||||
|
st.success("Success: Data exported successfully to warehouse.")
|
||||||
|
|
||||||
|
# Exception
|
||||||
|
st.subheader("Exception Display")
|
||||||
|
try:
|
||||||
|
raise ValueError("This is an example exception for demonstration")
|
||||||
|
except ValueError as e:
|
||||||
|
st.exception(e)
|
||||||
|
|
||||||
|
# Interactive status
|
||||||
|
st.subheader("Interactive Status")
|
||||||
|
status_cols = st.columns(3)
|
||||||
|
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
|
||||||
|
st.toast("This is a toast notification!", icon="🔔")
|
||||||
|
if status_cols[1].button("Balloons", icon=":material/celebration:"):
|
||||||
|
st.balloons()
|
||||||
|
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
|
||||||
|
st.snow()
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
st.subheader("Progress Indicators")
|
||||||
|
st.progress(0.7, text="70% complete")
|
||||||
|
with st.spinner("Loading..."):
|
||||||
|
st.write("Spinner is active (non-blocking in this demo)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SIDEBAR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
|
||||||
|
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
|
||||||
|
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
|
||||||
|
st.divider()
|
||||||
|
st.caption("Element Explorer v1.0")
|
||||||
|
st.caption("Theme: **Stripe**")
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
# ── Python ──────────────────────────────────────────────
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# ── Virtual environments ───────────────────────────────
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# ── Build / Distribution ──────────────────────────────
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.whl
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# ── Testing / Coverage ────────────────────────────────
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# ── Linting / Type checking ──────────────────────────
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# ── Runtime data (user-specific, contains device IPs/configs) ─
|
||||||
|
data/
|
||||||
|
|
||||||
|
# ── AI conversation history ──────────────────────────
|
||||||
|
.specstory/
|
||||||
|
|
||||||
|
# ── IDE / Editor ─────────────────────────────────────
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ── OS ───────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ── Secrets / Credentials (preventive) ──────────────
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
credentials.json
|
||||||
|
secrets.json
|
||||||
|
|
||||||
|
# ── Logs ─────────────────────────────────────────────
|
||||||
|
*.log
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[theme]
|
||||||
|
base = "dark"
|
||||||
|
primaryColor = "#4dabf7"
|
||||||
|
backgroundColor = "#0d1117"
|
||||||
|
secondaryBackgroundColor = "#161b22"
|
||||||
|
textColor = "#c9d1d9"
|
||||||
|
font = "sans serif"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
headless = true
|
||||||
|
# Rerun the app automatically when Python sources change (same as `shelly-manager-ui` defaults).
|
||||||
|
runOnSave = true
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Shelly Manager
|
||||||
|
|
||||||
|
Manage Shelly Gen1 and Gen2+ devices on your LAN: discovery (mDNS + subnet scan), inventory, configuration, and mass operations.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- [uv](https://docs.astral.sh/uv/) recommended
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd shelly-ui
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Pytest is configured to disable the `unraisableexception` plugin: Streamlit’s `AppTest` runs pages that call `asyncio.run()`, and teardown can emit `ResourceWarning` for sockets/event loops that would otherwise make pytest exit with an `ExceptionGroup` even when all tests pass.
|
||||||
|
|
||||||
|
- **Unit / async tests**: models; subnet scan (`fetch_shelly_json` mocked); **discovery helpers** (IPv6 URL host brackets, `probe_ip` port); SQLite + Markdown storage; `DeviceManager` (mocked mDNS + enrich). Real mDNS/zeroconf is not run in CI (no devices/network dependency).
|
||||||
|
- **Streamlit UI tests**: [`streamlit.testing.v1.AppTest`](https://docs.streamlit.io/develop/api-reference/app-testing) runs the home page and multipage scripts in-process — **no live browser or running `streamlit run` required**.
|
||||||
|
|
||||||
|
To add **browser E2E** tests against your local UI (`http://localhost:8501`), use Playwright or Selenium in a separate optional suite; `AppTest` already covers UI structure without flakiness from a real server.
|
||||||
|
|
||||||
|
## CLI (Textual TUI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run shelly-manager
|
||||||
|
# or
|
||||||
|
uv run python -m shelly_manager.cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run shelly-manager --help
|
||||||
|
uv run shelly-manager --storage sqlite --db-path ./data/devices.db
|
||||||
|
uv run shelly-manager --storage markdown --markdown-dir ./data/devices_md
|
||||||
|
```
|
||||||
|
|
||||||
|
The **Textual TUI** uses the same **`DeviceFilter`** as the web UI: generation, online/auth, combined name search, model & firmware substrings, IP prefix & MAC, comma-separated **tags** and **capabilities**, and dropdown **presets** for cached **settings** and **status** JSON. The Streamlit app adds **exact model multiselect**, **tag multiselect**, and **custom dot- or `|`-separated paths** for settings/status (use `|` when a key contains `:` e.g. `switch:0|output`).
|
||||||
|
|
||||||
|
## Web UI (Streamlit)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run shelly-manager-ui
|
||||||
|
# or
|
||||||
|
uv run streamlit run src/shelly_manager/ui/Dashboard.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hot reload:** `shelly-manager-ui` enables Streamlit’s **Run on save** (`server.runOnSave`), so edits to the app or imported modules trigger an automatic rerun. To disable (manual “Rerun” only), set `SHELLY_UI_NO_RELOAD=1`. Project [`.streamlit/config.toml`](.streamlit/config.toml) also sets `runOnSave` when you use `streamlit run` from the repo root.
|
||||||
|
|
||||||
|
**Settings persistence:** The **Settings** page writes **`data/shelly_ui_config.json`** (by default, relative to the process working directory — run from the repo root so `data/` is stable). Override the path with env **`SHELLY_UI_CONFIG`**. Without this file, settings lived only in browser session state and were lost on restart.
|
||||||
|
|
||||||
|
**Text fields:** Single-line inputs use a **✕** control on the right of the input row to clear the field in one click (Streamlit does not render a native in-field clear icon; multi-line JSON fields show **✕** on the top-right of the block). Implementation: `shelly_manager.ui.components.clearable_input`.
|
||||||
|
|
||||||
|
Each device shows an **Open device web UI** link using the device’s HTTP URL (`http://…/`), with correct bracketing for IPv6. Non-default ports from mDNS discovery are stored on the device as `http_port`.
|
||||||
|
|
||||||
|
**Discovery:** The app probes **mDNS** (`_shelly._tcp` / `_http._tcp`) and an optional **subnet CIDR** (`GET http://IP/shelly` on each address — use `192.168.x.0/24` for a whole LAN, or `192.168.x.y/32` for one IP). To add a **single device** by address, use **Dashboard → Add Shelly device manually** (IP only). Each discovery run uses the **latest saved settings** (`get_config()`), not only the in-memory `DeviceManager` snapshot. **Dashboard → Refresh inventory from network** only re-fetches devices **already in inventory**; it does **not** scan the subnet and ignores Subnet CIDR for finding new addresses. **Discovery details** on the Dashboard shows a **text log** (devices found + source) and JSON stats. The Python logger **`shelly_manager.core.device_manager`** also emits **INFO** lines per device. Devices that require login may not expose `/shelly` without auth.
|
||||||
|
|
||||||
|
The **Dashboard** page (`Dashboard.py`) is the main inventory: an **Inventory overview** with metrics (totals, online/offline, Gen1/2/3, auth, reboot, firmware-check buckets, tags) plus bar charts for generation and firmware status, **top models** and **capabilities** tables. **Click the numbers** in the overview to set URL query parameters (`?preset=…`, or `?preset=model&model=…` / `?cap=…` for model/cap rows) so **Filters** match that slice (applied once, then cleared from the URL). Then discovery, **Add Shelly device manually** (single IP), the same **inline filters** as Mass Config (including **Tag filter**: any / has tags / untagged), **visible columns** (shared preference), and a read-only table (including **Needs reboot** from `Shelly.GetStatus` → `sys.restart_required` after a live refresh, plus **Reboot filtered devices that need reboot**) (device **name** / **IP** links, **Config snapshots** column with the last *N* stored GetConfig snapshot labels per device — *N* is **Settings → Config snapshots shown per device**). **FW update** uses **`Shelly.CheckForUpdate`** when you run **Check firmware**, and otherwise falls back to **`Shelly.GetStatus` → `sys.available_updates`** (same information the device web UI uses; updated periodically on the device). Stored CheckForUpdate results are **kept** across **refresh** (they used to be cleared). Use **Check firmware** or **Refresh** so the column stays current. Filter **FW update = Has stable update** (default “offered” meaning) for devices with a **stable** channel build; use **Beta only** or **Stable or beta** to include beta-only offers. Additional pages: **Device**, **Mass Config**, **Settings** (see `src/shelly_manager/ui/pages/`). Dark theme: [`.streamlit/config.toml`](.streamlit/config.toml) ([Streamlit theming](https://docs.streamlit.io/develop/concepts/configuration/theming)).
|
||||||
|
|
||||||
|
**Device page:** **Firmware** section shows the installed version, runs **`Shelly.CheckForUpdate`**, and can start **`Shelly.Update`** OTA (**stable** or **beta**) on Gen2+ without auth (device must reach the internet; the device reboots when the install finishes). Builds a **dynamic editor** from the last **`Shelly.GetConfig`** snapshot (one expander per top-level key: `sys`, `wifi`, `switch:0`, …). Edit scalars and nested objects in the form; **lists** are edited as JSON. **Save to device** (Gen2+) sends each **changed** section via `Component.SetConfig` RPC; then the inventory is refreshed. The **Last config apply** expander lists each section with a **Restart required** column when the RPC response asks for a reboot; you can **Reboot device now** from the UI. **Gen1** only saves to **local inventory** (apply on the device separately). **Discard** resets the form widgets. Authenticated-only devices show raw JSON until credentials are supported. From the Dashboard or Mass Config inventory table, **click the device name** to open the in-app Device page, or **click the IP** to open the device’s **`http://` web UI** (typically in a new tab). You can also open a device via **`?device=<id>`** in the URL. **Configuration history** compares stored **GetConfig** snapshots as a unified diff (snapshot count per device is capped in **Settings**).
|
||||||
|
|
||||||
|
**Mass configuration:** Table-first: **filters** narrow *which devices appear* (same layout as the **Dashboard**). **Include** checkboxes + **Select / deselect all** choose *which filtered rows* receive an action — e.g. filter **Needs reboot = yes**, include those rows, then **Bulk actions → Device control → Reboot devices**. **Action category** groups **Device control** (reboot, identify), **Diagnostics** (live refresh, firmware check), and **RPC configuration** (BLE / MQTT / Cloud). **Custom section config** lets you paste a JSON object for **one** top-level GetConfig key (`mqtt`, `wifi`, `coiot`, `sntp`, `sys`, …) with **merge** or **replace**, for advanced bulk edits (same `Component.SetConfig` path as the Device page). **Tags** apply to selected rows only. **Latest bulk operation results** stays until dismissed. **Refresh table** live-refreshes listed devices. **Settings → Mass Config: refresh table after bulk operation** still applies after bulk runs. **Name** / **IP** are links. Gen2+-only actions skip Gen1 where appropriate.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
- `shelly_manager.core` — models, discovery, device manager
|
||||||
|
- `shelly_manager.api` — aioshelly-based Gen1/Gen2 clients
|
||||||
|
- `shelly_manager.storage` — SQLite or Markdown persistence
|
||||||
|
- `shelly_manager.cli` — Textual app
|
||||||
|
- `shelly_manager.ui` — Streamlit multipage app
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- [Shelly Gen1 API](https://shelly-api-docs.shelly.cloud/gen1/)
|
||||||
|
- [Shelly Gen2+ API](https://shelly-api-docs.shelly.cloud/gen2/)
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
Authentication on devices is not implemented in this version; unauthenticated devices are supported.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
[project]
|
||||||
|
name = "shelly-manager"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Shelly device management: shared core, Textual CLI, Streamlit UI"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"aioshelly>=13.0.0",
|
||||||
|
"aiohttp>=3.9.0",
|
||||||
|
"aiosqlite>=0.20.0",
|
||||||
|
"pydantic>=2.5.0",
|
||||||
|
"zeroconf>=0.132.0",
|
||||||
|
"textual>=0.86.0",
|
||||||
|
"rich>=13.7.0",
|
||||||
|
"streamlit>=1.40.0",
|
||||||
|
"typer>=0.12.0",
|
||||||
|
"pyyaml>=6.0.0",
|
||||||
|
"watchdog>=6.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.24.0",
|
||||||
|
"ruff>=0.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
shelly-manager = "shelly_manager.cli.app:app_cli"
|
||||||
|
shelly-manager-ui = "shelly_manager.ui.entry:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
markers = [
|
||||||
|
"integration: real LAN / Shelly devices (enable with SHELLY_INTEGRATION=1)",
|
||||||
|
]
|
||||||
|
# Streamlit AppTest + asyncio.run in page scripts leave sockets/event loops that GC
|
||||||
|
# clears after tests; pytest's unraisableexception plugin then raises ExceptionGroup and
|
||||||
|
# exit code 1 even when all tests passed. Disable that plugin for this project.
|
||||||
|
addopts = "-p no:unraisableexception"
|
||||||
|
# Also quiet ResourceWarning text on stderr for the same teardown noise.
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore:unclosed event loop:ResourceWarning",
|
||||||
|
"ignore:unclosed <socket:ResourceWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9.0.2",
|
||||||
|
"pytest-asyncio>=1.3.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"developing-with-streamlit": {
|
||||||
|
"source": "streamlit/agent-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "d366960761de1ec907dee42ddbdc08b9c868584cebc8c9fe8e0efdccb4bc5db7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Shelly Manager — shared core, CLI, and Streamlit UI."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from shelly_manager.api import gen1, gen2
|
||||||
|
from shelly_manager.api.client import fetch_device_snapshot, probe_ip
|
||||||
|
from shelly_manager.api.context import ShellyRuntime
|
||||||
|
|
||||||
|
__all__ = ["ShellyRuntime", "fetch_device_snapshot", "probe_ip", "gen1", "gen2"]
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""Connect to Shelly devices via aioshelly and map to unified models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import BasicAuth
|
||||||
|
from aioshelly.block_device.device import BlockDevice
|
||||||
|
from aioshelly.common import ConnectionOptions
|
||||||
|
from aioshelly.exceptions import ShellyError
|
||||||
|
from aioshelly.rpc_device.device import RpcDevice
|
||||||
|
|
||||||
|
from shelly_manager.api.context import ShellyRuntime
|
||||||
|
from shelly_manager.core.discovery import classify_generation, fetch_shelly_json, normalize_mac
|
||||||
|
from shelly_manager.core.models import ShellyDevice, infer_capabilities
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _deepcopy_json(d: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return copy.deepcopy(d)
|
||||||
|
|
||||||
|
|
||||||
|
def block_device_to_model(dev: BlockDevice) -> ShellyDevice:
|
||||||
|
"""Map initialized BlockDevice to ShellyDevice."""
|
||||||
|
mac = str(dev.shelly.get("mac", ""))
|
||||||
|
gen = 1
|
||||||
|
caps = infer_capabilities(gen, _deepcopy_json(dev.status), _deepcopy_json(dev.settings))
|
||||||
|
return ShellyDevice(
|
||||||
|
id=normalize_mac(mac),
|
||||||
|
name=dev.name,
|
||||||
|
mac=mac,
|
||||||
|
ip=dev.ip_address,
|
||||||
|
generation=gen,
|
||||||
|
model=dev.model,
|
||||||
|
firmware=dev.firmware_version,
|
||||||
|
online=True,
|
||||||
|
capabilities=caps,
|
||||||
|
status=_deepcopy_json(dev.status),
|
||||||
|
settings=_deepcopy_json(dev.settings),
|
||||||
|
auth_required=bool(dev.shelly.get("auth")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rpc_device_to_model(dev: RpcDevice) -> ShellyDevice:
|
||||||
|
"""Map initialized RpcDevice to ShellyDevice."""
|
||||||
|
shelly = dev.shelly
|
||||||
|
mac = str(shelly.get("mac", ""))
|
||||||
|
gen = int(shelly.get("gen", 2))
|
||||||
|
gen = max(2, min(gen, 3))
|
||||||
|
caps = infer_capabilities(
|
||||||
|
gen,
|
||||||
|
_deepcopy_json(dev.status),
|
||||||
|
_deepcopy_json(dev.config),
|
||||||
|
)
|
||||||
|
return ShellyDevice(
|
||||||
|
id=normalize_mac(mac),
|
||||||
|
name=dev.name,
|
||||||
|
mac=mac,
|
||||||
|
ip=dev.ip_address,
|
||||||
|
generation=gen, # type: ignore[arg-type]
|
||||||
|
model=dev.model,
|
||||||
|
firmware=dev.firmware_version,
|
||||||
|
online=True,
|
||||||
|
capabilities=caps,
|
||||||
|
status=_deepcopy_json(dev.status),
|
||||||
|
settings=_deepcopy_json(dev.config),
|
||||||
|
auth_required=bool(shelly.get("auth_en")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_device_snapshot(runtime: ShellyRuntime, ip: str, generation: int) -> ShellyDevice:
|
||||||
|
"""
|
||||||
|
Connect, initialize, snapshot, shutdown — one-shot fetch.
|
||||||
|
|
||||||
|
Raises ShellyError subclasses on failure.
|
||||||
|
"""
|
||||||
|
if runtime.session is None or runtime.coap is None:
|
||||||
|
raise RuntimeError("ShellyRuntime not entered")
|
||||||
|
|
||||||
|
session = runtime.session
|
||||||
|
if generation >= 2:
|
||||||
|
dev = await RpcDevice.create(session, None, ip)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
return rpc_device_to_model(dev)
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
dev = await BlockDevice.create(session, runtime.coap, ip)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
return block_device_to_model(dev)
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_ip(
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
ip: str,
|
||||||
|
port: int = 80,
|
||||||
|
*,
|
||||||
|
timeout_sec: float = 3.0,
|
||||||
|
) -> tuple[int, dict[str, Any]] | None:
|
||||||
|
"""GET /shelly and return (generation, json) or None."""
|
||||||
|
data = await fetch_shelly_json(session, ip, port=port, timeout_sec=timeout_sec)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return classify_generation(data), data
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_with_auth(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
ip: str,
|
||||||
|
generation: int,
|
||||||
|
*,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> ShellyDevice:
|
||||||
|
"""Fetch snapshot with optional HTTP basic (Gen1) / digest prep (Gen2 via options)."""
|
||||||
|
if runtime.session is None or runtime.coap is None:
|
||||||
|
raise RuntimeError("ShellyRuntime not entered")
|
||||||
|
|
||||||
|
session = runtime.session
|
||||||
|
auth = None
|
||||||
|
if username and password:
|
||||||
|
auth = BasicAuth(username, password)
|
||||||
|
|
||||||
|
if generation >= 2:
|
||||||
|
opts = ConnectionOptions(ip_address=ip, username=username, password=password)
|
||||||
|
dev = await RpcDevice.create(session, None, opts)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
return rpc_device_to_model(dev)
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
|
||||||
|
opts = ConnectionOptions(ip_address=ip, username=username, password=password)
|
||||||
|
dev = await BlockDevice.create(session, runtime.coap, opts)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
return block_device_to_model(dev)
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
async def reboot_device(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
ip: str,
|
||||||
|
generation: int,
|
||||||
|
*,
|
||||||
|
http_port: int = 80,
|
||||||
|
) -> None:
|
||||||
|
"""Request a device reboot (Gen2+ WebSocket RPC or Gen1 HTTP)."""
|
||||||
|
if runtime.session is None or runtime.coap is None:
|
||||||
|
raise RuntimeError("ShellyRuntime not entered")
|
||||||
|
if generation >= 2:
|
||||||
|
port = int(http_port or 80)
|
||||||
|
opts = ConnectionOptions(ip_address=ip, port=port)
|
||||||
|
dev = await RpcDevice.create(runtime.session, None, opts)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
await dev.trigger_reboot()
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
else:
|
||||||
|
dev = await BlockDevice.create(runtime.session, runtime.coap, ip)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
await dev.trigger_reboot()
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_for_update(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
ip: str,
|
||||||
|
generation: int,
|
||||||
|
*,
|
||||||
|
http_port: int = 80,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Call ``Shelly.CheckForUpdate``. Empty result means no firmware update is available (Gen2+)."""
|
||||||
|
if runtime.session is None:
|
||||||
|
raise RuntimeError("ShellyRuntime not entered")
|
||||||
|
if generation < 2:
|
||||||
|
return {}
|
||||||
|
port = int(http_port or 80)
|
||||||
|
opts = ConnectionOptions(ip_address=ip, port=port)
|
||||||
|
dev = await RpcDevice.create(runtime.session, None, opts)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
return await dev.call_rpc("Shelly.CheckForUpdate")
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_firmware_update(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
ip: str,
|
||||||
|
generation: int,
|
||||||
|
*,
|
||||||
|
http_port: int = 80,
|
||||||
|
beta: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Call ``Shelly.Update`` (OTA). Device typically reboots when the install finishes."""
|
||||||
|
if runtime.session is None:
|
||||||
|
raise RuntimeError("ShellyRuntime not entered")
|
||||||
|
if generation < 2:
|
||||||
|
raise ValueError("Firmware update over RPC requires Gen2+ (Gen1: use the device web UI).")
|
||||||
|
port = int(http_port or 80)
|
||||||
|
opts = ConnectionOptions(ip_address=ip, port=port)
|
||||||
|
dev = await RpcDevice.create(runtime.session, None, opts)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
await dev.trigger_ota_update(beta=beta)
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
async def identify_device(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
ip: str,
|
||||||
|
generation: int,
|
||||||
|
*,
|
||||||
|
http_port: int = 80,
|
||||||
|
) -> None:
|
||||||
|
"""Flash LEDs / identify — Gen2+ ``Shelly.Identify`` (uses ``http_port``)."""
|
||||||
|
if runtime.session is None:
|
||||||
|
raise RuntimeError("ShellyRuntime not entered")
|
||||||
|
if generation < 2:
|
||||||
|
raise ValueError("Shelly.Identify requires Gen2+")
|
||||||
|
port = int(http_port or 80)
|
||||||
|
opts = ConnectionOptions(ip_address=ip, port=port)
|
||||||
|
dev = await RpcDevice.create(runtime.session, None, opts)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
await dev.call_rpc("Shelly.Identify")
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Shared aiohttp session + Gen1 CoAP context."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aioshelly.block_device.coap import COAP
|
||||||
|
|
||||||
|
|
||||||
|
class ShellyRuntime:
|
||||||
|
"""Lifecycle for aioshelly BlockDevice (CoAP) + shared HTTP session."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.session: aiohttp.ClientSession | None = None
|
||||||
|
self.coap: COAP | None = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> ShellyRuntime:
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
self.coap = COAP()
|
||||||
|
await self.coap.initialize()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: object) -> None:
|
||||||
|
if self.coap is not None:
|
||||||
|
self.coap.close()
|
||||||
|
self.coap = None
|
||||||
|
if self.session is not None:
|
||||||
|
await self.session.close()
|
||||||
|
self.session = None
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Gen1 (CoAP/HTTP) helpers — re-exports from the unified client."""
|
||||||
|
|
||||||
|
from shelly_manager.api.client import block_device_to_model, fetch_device_snapshot
|
||||||
|
from shelly_manager.api.context import ShellyRuntime
|
||||||
|
|
||||||
|
__all__ = ["ShellyRuntime", "block_device_to_model", "fetch_device_snapshot"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Gen2+ RPC helpers — re-exports from the unified client."""
|
||||||
|
|
||||||
|
from shelly_manager.api.client import fetch_device_snapshot, rpc_device_to_model
|
||||||
|
from shelly_manager.api.context import ShellyRuntime
|
||||||
|
|
||||||
|
__all__ = ["ShellyRuntime", "rpc_device_to_model", "fetch_device_snapshot"]
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Apply Shelly Gen2+ GetConfig-shaped updates via per-component *.SetConfig RPC calls."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aioshelly.common import ConnectionOptions
|
||||||
|
from aioshelly.exceptions import RpcCallError, ShellyError
|
||||||
|
from aioshelly.rpc_device.device import RpcDevice
|
||||||
|
|
||||||
|
from shelly_manager.api.context import ShellyRuntime
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_equal(a: Any, b: Any) -> bool:
|
||||||
|
try:
|
||||||
|
return json.dumps(a, sort_keys=True, default=str) == json.dumps(
|
||||||
|
b, sort_keys=True, default=str
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return a == b
|
||||||
|
|
||||||
|
|
||||||
|
def _component_class_name(segment: str) -> str:
|
||||||
|
"""Map first segment of a GetConfig key to RPC component class name."""
|
||||||
|
s = segment.strip().lower()
|
||||||
|
overrides: dict[str, str] = {
|
||||||
|
"wifi": "WiFi",
|
||||||
|
"ws": "Ws",
|
||||||
|
"ble": "BLE",
|
||||||
|
"mqtt": "MQTT",
|
||||||
|
"cloud": "Cloud",
|
||||||
|
"sys": "Sys",
|
||||||
|
"eth": "Eth",
|
||||||
|
"em": "EM",
|
||||||
|
"em1": "EM1",
|
||||||
|
"em1data": "EM1Data",
|
||||||
|
"emdata": "EMData",
|
||||||
|
"humidity": "Humidity",
|
||||||
|
"temperature": "Temperature",
|
||||||
|
"input": "Input",
|
||||||
|
"switch": "Switch",
|
||||||
|
"cover": "Cover",
|
||||||
|
"light": "Light",
|
||||||
|
"script": "Script",
|
||||||
|
"thermostat": "Thermostat",
|
||||||
|
"rgbw": "RGBW",
|
||||||
|
"rgb": "RGB",
|
||||||
|
"plugsui": "PlugsUI",
|
||||||
|
"enum": "Enum",
|
||||||
|
"number": "Number",
|
||||||
|
"text": "Text",
|
||||||
|
"boolean": "Boolean",
|
||||||
|
"pm1": "PM1",
|
||||||
|
"virtual": "Virtual",
|
||||||
|
}
|
||||||
|
if s in overrides:
|
||||||
|
return overrides[s]
|
||||||
|
if not segment:
|
||||||
|
return segment
|
||||||
|
return segment[0].upper() + segment[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_top_level_key(key: str) -> tuple[str, int | None]:
|
||||||
|
"""Return (component_segment, id_or_none). Keys look like `switch:0` or `sys`."""
|
||||||
|
if ":" in key:
|
||||||
|
comp, rest = key.split(":", 1)
|
||||||
|
try:
|
||||||
|
return comp, int(rest)
|
||||||
|
except ValueError:
|
||||||
|
return key, None
|
||||||
|
return key, None
|
||||||
|
|
||||||
|
|
||||||
|
def set_config_method_and_params(key: str, config: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Build RPC method name and params for Shelly.GetConfig top-level key."""
|
||||||
|
comp, idx = _parse_top_level_key(key)
|
||||||
|
cls = _component_class_name(comp)
|
||||||
|
method = f"{cls}.SetConfig"
|
||||||
|
params: dict[str, Any] = {"config": config}
|
||||||
|
if idx is not None:
|
||||||
|
params["id"] = idx
|
||||||
|
return method, params
|
||||||
|
|
||||||
|
|
||||||
|
def iter_changed_top_level_keys(
|
||||||
|
old_cfg: dict[str, Any],
|
||||||
|
new_cfg: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Top-level GetConfig keys whose value differs (JSON-normalized)."""
|
||||||
|
keys: list[str] = []
|
||||||
|
for key in sorted(set(old_cfg.keys()) | set(new_cfg.keys())):
|
||||||
|
if key not in new_cfg:
|
||||||
|
continue
|
||||||
|
old_v = old_cfg.get(key)
|
||||||
|
new_v = new_cfg[key]
|
||||||
|
if _deep_equal(old_v, new_v):
|
||||||
|
continue
|
||||||
|
keys.append(key)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_gen2_config_diff(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
ip: str,
|
||||||
|
old_cfg: dict[str, Any],
|
||||||
|
new_cfg: dict[str, Any],
|
||||||
|
*,
|
||||||
|
port: int = 80,
|
||||||
|
) -> list[tuple[str, str, bool]]:
|
||||||
|
"""
|
||||||
|
For each top-level key whose value changed, call Component.SetConfig.
|
||||||
|
|
||||||
|
Returns list of ``(key, message, restart_required)`` where *message* is ``\"ok\"`` or an
|
||||||
|
error description. *restart_required* is taken from the RPC response when successful.
|
||||||
|
"""
|
||||||
|
if runtime.session is None:
|
||||||
|
raise RuntimeError("ShellyRuntime not entered")
|
||||||
|
|
||||||
|
opts = ConnectionOptions(ip_address=ip, port=port)
|
||||||
|
dev = await RpcDevice.create(runtime.session, None, opts)
|
||||||
|
await dev.initialize()
|
||||||
|
results: list[tuple[str, str, bool]] = []
|
||||||
|
try:
|
||||||
|
for key in iter_changed_top_level_keys(old_cfg, new_cfg):
|
||||||
|
new_v = new_cfg[key]
|
||||||
|
if not isinstance(new_v, dict):
|
||||||
|
results.append(
|
||||||
|
(
|
||||||
|
key,
|
||||||
|
f"skip: top-level value must be object, got {type(new_v).__name__}",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
method, params = set_config_method_and_params(key, new_v)
|
||||||
|
try:
|
||||||
|
resp = await dev.call_rpc(method, params)
|
||||||
|
rr = bool(resp.get("restart_required")) if isinstance(resp, dict) else False
|
||||||
|
results.append((key, "ok", rr))
|
||||||
|
except RpcCallError as err:
|
||||||
|
_LOGGER.warning("RPC %s %s: %s", method, key, err)
|
||||||
|
results.append((key, f"RPC {err.code}: {err.message or str(err)}", False))
|
||||||
|
except ShellyError as err:
|
||||||
|
_LOGGER.warning("RPC %s %s: %s", method, key, err)
|
||||||
|
results.append((key, str(err), False))
|
||||||
|
return results
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Apply mass configuration operations via aioshelly (Gen2+ RPC)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from aioshelly.common import ConnectionOptions
|
||||||
|
from aioshelly.exceptions import RpcCallError, ShellyError
|
||||||
|
from aioshelly.rpc_device.device import RpcDevice
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from shelly_manager.api.client import fetch_device_snapshot
|
||||||
|
from shelly_manager.api.context import ShellyRuntime
|
||||||
|
from shelly_manager.core.mass_config import get_mass_operation
|
||||||
|
from shelly_manager.core.models import ShellyDevice, preserve_firmware_check_metadata
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RPC_METHOD_NOT_FOUND = -114
|
||||||
|
|
||||||
|
|
||||||
|
def _rpc_error_detail(err: RpcCallError) -> str:
|
||||||
|
"""Human-readable RPC failure for UI and logs."""
|
||||||
|
msg = (err.message or "").strip() or str(err)
|
||||||
|
return f"RPC {err.code}: {msg}"
|
||||||
|
|
||||||
|
|
||||||
|
class MassApplyResult(BaseModel):
|
||||||
|
"""Result of applying one mass-config operation to one device."""
|
||||||
|
|
||||||
|
device_id: str
|
||||||
|
display_name: str
|
||||||
|
status: str # ok | skipped | error
|
||||||
|
detail: str = ""
|
||||||
|
restart_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _with_rpc(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
device: ShellyDevice,
|
||||||
|
fn: Callable[[RpcDevice], Awaitable[MassApplyResult]],
|
||||||
|
) -> MassApplyResult:
|
||||||
|
"""Run async callable(dev: RpcDevice) with a connected device (respects ``http_port``)."""
|
||||||
|
if runtime.session is None:
|
||||||
|
raise RuntimeError("ShellyRuntime not entered")
|
||||||
|
port = int(device.http_port or 80)
|
||||||
|
opts = ConnectionOptions(ip_address=device.ip, port=port)
|
||||||
|
dev = await RpcDevice.create(runtime.session, None, opts)
|
||||||
|
await dev.initialize()
|
||||||
|
try:
|
||||||
|
return await fn(dev)
|
||||||
|
finally:
|
||||||
|
await dev.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_ble_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
|
||||||
|
async def go(dev: RpcDevice) -> MassApplyResult:
|
||||||
|
out: dict | object
|
||||||
|
try:
|
||||||
|
out = await dev.ble_setconfig(False, False)
|
||||||
|
except RpcCallError as err:
|
||||||
|
if err.code == RPC_METHOD_NOT_FOUND:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="skipped",
|
||||||
|
detail="BLE.SetConfig not available on this model/firmware",
|
||||||
|
)
|
||||||
|
# Some firmware rejects nested ``rpc`` when disabling — try minimal payload.
|
||||||
|
try:
|
||||||
|
out = await dev.call_rpc("BLE.SetConfig", {"config": {"enable": False}})
|
||||||
|
except RpcCallError as err2:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="error",
|
||||||
|
detail=_rpc_error_detail(err2),
|
||||||
|
)
|
||||||
|
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="ok",
|
||||||
|
detail="BLE.SetConfig disable",
|
||||||
|
restart_required=rr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _with_rpc(runtime, device, go)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_ble_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
|
||||||
|
async def go(dev: RpcDevice) -> MassApplyResult:
|
||||||
|
out: dict | object
|
||||||
|
try:
|
||||||
|
out = await dev.ble_setconfig(True, True)
|
||||||
|
except RpcCallError as err:
|
||||||
|
if err.code == RPC_METHOD_NOT_FOUND:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="skipped",
|
||||||
|
detail="BLE.SetConfig not available on this model/firmware",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out = await dev.call_rpc(
|
||||||
|
"BLE.SetConfig",
|
||||||
|
{"config": {"enable": True, "rpc": {"enable": True}}},
|
||||||
|
)
|
||||||
|
except RpcCallError as err2:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="error",
|
||||||
|
detail=_rpc_error_detail(err2),
|
||||||
|
)
|
||||||
|
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="ok",
|
||||||
|
detail="BLE.SetConfig enable",
|
||||||
|
restart_required=rr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _with_rpc(runtime, device, go)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_mqtt_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
|
||||||
|
async def go(dev: RpcDevice) -> MassApplyResult:
|
||||||
|
try:
|
||||||
|
out = await dev.call_rpc("MQTT.SetConfig", {"config": {"enable": False}})
|
||||||
|
except RpcCallError as err:
|
||||||
|
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="skipped",
|
||||||
|
detail="MQTT.SetConfig not available on this model",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="ok",
|
||||||
|
detail="MQTT.SetConfig enable=false",
|
||||||
|
restart_required=rr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _with_rpc(runtime, device, go)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_mqtt_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
|
||||||
|
async def go(dev: RpcDevice) -> MassApplyResult:
|
||||||
|
try:
|
||||||
|
out = await dev.call_rpc("MQTT.SetConfig", {"config": {"enable": True}})
|
||||||
|
except RpcCallError as err:
|
||||||
|
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="skipped",
|
||||||
|
detail="MQTT.SetConfig not available on this model",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="ok",
|
||||||
|
detail="MQTT.SetConfig enable=true",
|
||||||
|
restart_required=rr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _with_rpc(runtime, device, go)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_cloud_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
|
||||||
|
async def go(dev: RpcDevice) -> MassApplyResult:
|
||||||
|
try:
|
||||||
|
out = await dev.call_rpc("Cloud.SetConfig", {"config": {"enable": False}})
|
||||||
|
except RpcCallError as err:
|
||||||
|
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="skipped",
|
||||||
|
detail="Cloud.SetConfig not available on this model",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="ok",
|
||||||
|
detail="Cloud.SetConfig enable=false",
|
||||||
|
restart_required=rr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _with_rpc(runtime, device, go)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_cloud_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
|
||||||
|
async def go(dev: RpcDevice) -> MassApplyResult:
|
||||||
|
try:
|
||||||
|
out = await dev.call_rpc("Cloud.SetConfig", {"config": {"enable": True}})
|
||||||
|
except RpcCallError as err:
|
||||||
|
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="skipped",
|
||||||
|
detail="Cloud.SetConfig not available on this model",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="ok",
|
||||||
|
detail="Cloud.SetConfig enable=true",
|
||||||
|
restart_required=rr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _with_rpc(runtime, device, go)
|
||||||
|
|
||||||
|
|
||||||
|
_HANDLERS = {
|
||||||
|
"ble_disable": _apply_ble_disable,
|
||||||
|
"ble_enable": _apply_ble_enable,
|
||||||
|
"mqtt_disable": _apply_mqtt_disable,
|
||||||
|
"mqtt_enable": _apply_mqtt_enable,
|
||||||
|
"cloud_disable": _apply_cloud_disable,
|
||||||
|
"cloud_enable": _apply_cloud_enable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_mass_operation(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
device: ShellyDevice,
|
||||||
|
operation_id: str,
|
||||||
|
) -> MassApplyResult:
|
||||||
|
"""Apply a single **RPC config** operation to one device (caller persists refreshed snapshot)."""
|
||||||
|
meta = get_mass_operation(operation_id)
|
||||||
|
if not meta:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="error",
|
||||||
|
detail=f"Unknown operation {operation_id!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if meta.kind != "rpc_config":
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="error",
|
||||||
|
detail=f"Operation {operation_id!r} is not an RPC config action — use DeviceManager.mass_apply_operation",
|
||||||
|
)
|
||||||
|
|
||||||
|
if device.generation not in meta.supported_generations:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="skipped",
|
||||||
|
detail=f"Not supported for generation {device.generation}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if device.auth_required:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="skipped",
|
||||||
|
detail="Authentication required — configure credentials first",
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = _HANDLERS.get(operation_id)
|
||||||
|
if not handler:
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="error",
|
||||||
|
detail=f"No handler implemented for {operation_id!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await handler(runtime, device)
|
||||||
|
except ShellyError as err:
|
||||||
|
_LOGGER.warning("mass apply %s on %s: %s", operation_id, device.id, err)
|
||||||
|
detail = _rpc_error_detail(err) if isinstance(err, RpcCallError) else str(err)
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="error",
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.exception("mass apply %s on %s", operation_id, device.id)
|
||||||
|
return MassApplyResult(
|
||||||
|
device_id=device.id,
|
||||||
|
display_name=device.display_name,
|
||||||
|
status="error",
|
||||||
|
detail=str(err),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_device_after_apply(
|
||||||
|
runtime: ShellyRuntime,
|
||||||
|
device: ShellyDevice,
|
||||||
|
) -> ShellyDevice | None:
|
||||||
|
"""Fetch fresh Shelly snapshot and preserve tags/notes."""
|
||||||
|
fresh = await fetch_device_snapshot(runtime, device.ip, device.generation)
|
||||||
|
fresh.tags = device.tags
|
||||||
|
fresh.notes = device.notes
|
||||||
|
fresh.http_port = device.http_port
|
||||||
|
preserve_firmware_check_metadata(fresh, device)
|
||||||
|
return fresh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Textual TUI for Shelly Manager."""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from shelly_manager.cli.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Typer entry + Textual main loop."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Container, Vertical
|
||||||
|
from textual.widgets import Footer, Header, ListItem, ListView, Static
|
||||||
|
|
||||||
|
from shelly_manager.cli.screens.dashboard import DashboardScreen
|
||||||
|
from shelly_manager.cli.screens.discovery import DiscoveryScreen
|
||||||
|
from shelly_manager.cli.screens.mass_config import MassConfigScreen
|
||||||
|
from shelly_manager.core.config import AppConfig
|
||||||
|
from shelly_manager.core.device_manager import DeviceManager, storage_from_config
|
||||||
|
|
||||||
|
app_cli = typer.Typer(add_completion=False, no_args_is_help=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MainMenu(App[None]):
|
||||||
|
"""Root menu to open sub-screens."""
|
||||||
|
|
||||||
|
BINDINGS = [Binding("q", "quit", "Quit"), Binding("escape", "quit", "Quit")]
|
||||||
|
|
||||||
|
def __init__(self, dm: DeviceManager) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.dm = dm
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(show_clock=True)
|
||||||
|
with Container():
|
||||||
|
with Vertical(id="menu"):
|
||||||
|
yield Static("[bold]Shelly Manager[/]\n")
|
||||||
|
yield ListView(
|
||||||
|
ListItem(Static("Devices — inventory & refresh")),
|
||||||
|
ListItem(Static("Discover — mDNS + subnet scan")),
|
||||||
|
ListItem(Static("Mass tags — filter + apply tags")),
|
||||||
|
id="main_list",
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||||
|
idx = event.index
|
||||||
|
if idx == 0:
|
||||||
|
self.push_screen(DashboardScreen(self.dm))
|
||||||
|
elif idx == 1:
|
||||||
|
self.push_screen(DiscoveryScreen(self.dm))
|
||||||
|
elif idx == 2:
|
||||||
|
self.push_screen(MassConfigScreen(self.dm))
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_config(
|
||||||
|
storage: str,
|
||||||
|
db_path: Path | None,
|
||||||
|
markdown_dir: Path | None,
|
||||||
|
subnet: str | None,
|
||||||
|
) -> AppConfig:
|
||||||
|
cfg = AppConfig(
|
||||||
|
storage_backend="sqlite" if storage == "sqlite" else "markdown",
|
||||||
|
subnet_scan_cidr=subnet,
|
||||||
|
)
|
||||||
|
if db_path is not None:
|
||||||
|
cfg.sqlite_path = db_path
|
||||||
|
if markdown_dir is not None:
|
||||||
|
cfg.markdown_dir = markdown_dir
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@app_cli.command()
|
||||||
|
def main(
|
||||||
|
storage: str = typer.Option("sqlite", "--storage", help="sqlite or markdown"),
|
||||||
|
db_path: Path | None = typer.Option(None, "--db-path", help="SQLite file path"),
|
||||||
|
markdown_dir: Path | None = typer.Option(None, "--markdown-dir", help="Markdown root dir"),
|
||||||
|
subnet: str | None = typer.Option(None, "--subnet", help="Optional CIDR e.g. 192.168.1.0/24"),
|
||||||
|
) -> None:
|
||||||
|
"""Launch the Textual TUI."""
|
||||||
|
cfg = _build_config(storage, db_path, markdown_dir, subnet)
|
||||||
|
dm = DeviceManager(cfg, storage_from_config(cfg))
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
tui = MainMenu(dm)
|
||||||
|
await tui.run_async()
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app_cli()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Textual screens."""
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Main device table."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual import work
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Vertical
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import DataTable, Footer, Header, Static
|
||||||
|
|
||||||
|
from shelly_manager.cli.screens.device_detail import DeviceDetailScreen
|
||||||
|
from shelly_manager.cli.widgets.filter_bar import FilterBar
|
||||||
|
from shelly_manager.core.device_manager import DeviceManager
|
||||||
|
from shelly_manager.core.model_names import format_model_plain
|
||||||
|
from shelly_manager.core.models import ShellyDevice
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardScreen(Screen[None]):
|
||||||
|
"""Device list with filters."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("r", "refresh", "Refresh"),
|
||||||
|
Binding("d", "detail", "Detail"),
|
||||||
|
Binding("f", "filter_reload", "apply"),
|
||||||
|
Binding("q", "quit", "Quit"),
|
||||||
|
Binding("escape", "quit", "Quit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, dm: DeviceManager) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.dm = dm
|
||||||
|
self._devices: list[ShellyDevice] = []
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(show_clock=True)
|
||||||
|
with Vertical():
|
||||||
|
yield Static("[bold]Shelly Manager[/] — [dim]r refresh · d detail · f reload filters · q quit[/]")
|
||||||
|
yield FilterBar(id="filters")
|
||||||
|
yield DataTable(
|
||||||
|
id="table",
|
||||||
|
cursor_type="row",
|
||||||
|
zebra_stripes=True,
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
table = self.query_one("#table", DataTable)
|
||||||
|
table.add_columns("Name", "IP", "URL", "Gen", "Model", "FW", "Online", "Caps")
|
||||||
|
self.load_table()
|
||||||
|
|
||||||
|
@work(exclusive=True)
|
||||||
|
async def load_table(self) -> None:
|
||||||
|
filt = self.query_one(FilterBar).build_filter()
|
||||||
|
devices = await self.dm.storage.list_devices(filt)
|
||||||
|
self._devices = devices
|
||||||
|
table = self.query_one("#table", DataTable)
|
||||||
|
table.clear(columns=True)
|
||||||
|
table.add_columns("Name", "IP", "URL", "Gen", "Model", "FW", "Online", "Caps")
|
||||||
|
for d in devices:
|
||||||
|
caps = ", ".join(d.capabilities[:3])
|
||||||
|
table.add_row(
|
||||||
|
d.display_name,
|
||||||
|
d.ip,
|
||||||
|
d.http_url,
|
||||||
|
str(d.generation),
|
||||||
|
format_model_plain(d.model),
|
||||||
|
d.firmware[:16] if d.firmware else "",
|
||||||
|
"yes" if d.online else "no",
|
||||||
|
caps,
|
||||||
|
key=d.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_refresh(self) -> None:
|
||||||
|
self.load_table()
|
||||||
|
|
||||||
|
def action_filter_reload(self) -> None:
|
||||||
|
self.load_table()
|
||||||
|
|
||||||
|
def action_detail(self) -> None:
|
||||||
|
table = self.query_one("#table", DataTable)
|
||||||
|
row = table.cursor_row
|
||||||
|
if row is None or row < 0 or row >= len(self._devices):
|
||||||
|
return
|
||||||
|
dev = self._devices[row]
|
||||||
|
self.app.push_screen(DeviceDetailScreen(self.dm, dev))
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
self.app.exit()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Single-device extended view."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from rich.console import Group
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Footer, Header, Static
|
||||||
|
|
||||||
|
from shelly_manager.core.device_manager import DeviceManager
|
||||||
|
from shelly_manager.core.model_names import format_model_plain
|
||||||
|
from shelly_manager.core.models import ShellyDevice
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDetailScreen(Screen[None]):
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "back", "Back"),
|
||||||
|
Binding("r", "refresh", "Refresh"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, dm: DeviceManager, device: ShellyDevice) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.dm = dm
|
||||||
|
self.device = device
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Container(id="detail_wrap"):
|
||||||
|
yield Static(id="detail_text")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def action_refresh(self) -> None:
|
||||||
|
self.run_worker(self._do_refresh, exclusive=True)
|
||||||
|
|
||||||
|
async def _do_refresh(self) -> None:
|
||||||
|
fresh = await self.dm.refresh_device(self.device.id)
|
||||||
|
if fresh:
|
||||||
|
self.device = fresh
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
d = self.device
|
||||||
|
# Use Rich renderables (Group / Text / Syntax), not Textual markup strings: URLs like
|
||||||
|
# ``http://...`` break ``[link=...]`` parsing (``:`` after ``http``), and JSON often
|
||||||
|
# contains ``[`` / ``]`` which Textual would treat as markup.
|
||||||
|
header = Text()
|
||||||
|
header.append(d.display_name, style="bold")
|
||||||
|
header.append(
|
||||||
|
f" {d.ip} Gen{d.generation} {format_model_plain(d.model)}\nWeb UI: ",
|
||||||
|
)
|
||||||
|
header.append(d.http_url, style=f"link {d.http_url}")
|
||||||
|
header.append("\n")
|
||||||
|
|
||||||
|
tip = Text.from_markup(
|
||||||
|
"[dim]Tip: run `shelly-manager-ui` → Device page for form-based config edit + save.[/dim]\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings_json = json.dumps(d.settings, indent=2)[:12000]
|
||||||
|
status_json = json.dumps(d.status, indent=2)[:12000]
|
||||||
|
settings_block = Syntax(settings_json, "json", word_wrap=True, line_numbers=False)
|
||||||
|
status_block = Syntax(status_json, "json", word_wrap=True, line_numbers=False)
|
||||||
|
|
||||||
|
body: Group = Group(
|
||||||
|
header,
|
||||||
|
tip,
|
||||||
|
Text("Settings\n", style="bold"),
|
||||||
|
settings_block,
|
||||||
|
Text("\n"),
|
||||||
|
Text("Status\n", style="bold"),
|
||||||
|
status_block,
|
||||||
|
)
|
||||||
|
self.query_one("#detail_text", Static).update(body)
|
||||||
|
|
||||||
|
def action_back(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Discovery progress log."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Vertical
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Footer, Header, Log
|
||||||
|
|
||||||
|
from shelly_manager.core.device_manager import DeviceManager
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveryScreen(Screen[None]):
|
||||||
|
BINDINGS = [Binding("escape", "back", "Back")]
|
||||||
|
|
||||||
|
def __init__(self, dm: DeviceManager) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.dm = dm
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Vertical():
|
||||||
|
yield Log(id="log", highlight=True)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.run_worker(self._run_discovery, exclusive=True)
|
||||||
|
|
||||||
|
async def _run_discovery(self) -> None:
|
||||||
|
log = self.query_one("#log", Log)
|
||||||
|
|
||||||
|
def on_progress(phase: str, msg: str) -> None:
|
||||||
|
log.write_line(f"[{phase}] {msg}")
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
await self.dm.discover_all(session=session, on_progress=on_progress)
|
||||||
|
log.write_line("[done] Discovery finished.")
|
||||||
|
|
||||||
|
def action_back(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Mass tag assignment via filters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Vertical
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Button, Footer, Header, Input, Static
|
||||||
|
|
||||||
|
from shelly_manager.cli.widgets.filter_bar import FilterBar
|
||||||
|
from shelly_manager.core.device_manager import DeviceManager
|
||||||
|
|
||||||
|
|
||||||
|
class MassConfigScreen(Screen[None]):
|
||||||
|
BINDINGS = [Binding("escape", "back", "Back")]
|
||||||
|
|
||||||
|
def __init__(self, dm: DeviceManager) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.dm = dm
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Vertical():
|
||||||
|
yield Static("[bold]Mass tags[/] — apply comma-separated tags to filtered devices.")
|
||||||
|
yield FilterBar(id="filters")
|
||||||
|
yield Input(placeholder="tags e.g. kitchen, floor-1", id="tags")
|
||||||
|
yield Button("Apply", variant="primary", id="apply")
|
||||||
|
yield Static(id="result")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "apply":
|
||||||
|
self.run_worker(self._apply_tags, exclusive=True)
|
||||||
|
|
||||||
|
async def _apply_tags(self) -> None:
|
||||||
|
filt = self.query_one(FilterBar).build_filter()
|
||||||
|
raw = self.query_one("#tags", Input).value or ""
|
||||||
|
tags = [t.strip() for t in raw.split(",") if t.strip()]
|
||||||
|
n = await self.dm.apply_mass_tags(filt, tags)
|
||||||
|
self.query_one("#result", Static).update(f"Updated {n} device(s).")
|
||||||
|
|
||||||
|
def action_back(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Compact device summary for lists."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from shelly_manager.core.model_names import format_model_plain
|
||||||
|
from shelly_manager.core.models import ShellyDevice
|
||||||
|
|
||||||
|
|
||||||
|
def device_card_text(d: ShellyDevice, extended: bool = False) -> str:
|
||||||
|
"""Rich-formatted card text."""
|
||||||
|
cap = ", ".join(d.capabilities[:5]) if d.capabilities else "—"
|
||||||
|
line = (
|
||||||
|
f"[bold]{d.display_name}[/] [dim]{d.ip}[/] "
|
||||||
|
f"[cyan]Gen{d.generation}[/] [magenta]{format_model_plain(d.model)}[/] "
|
||||||
|
f"{'[green]online[/]' if d.online else '[red]offline[/]'}"
|
||||||
|
)
|
||||||
|
if not extended:
|
||||||
|
return line + f"\n [dim]{cap}[/]"
|
||||||
|
st = json.dumps(d.status, indent=2)[:2000]
|
||||||
|
return line + f"\n[dim]{cap}[/]\n\n[bold]Status[/]\n{st}"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCard(Static):
|
||||||
|
"""Static panel showing one device."""
|
||||||
|
|
||||||
|
def __init__(self, device: ShellyDevice, extended: bool = False, **kwargs: object) -> None:
|
||||||
|
super().__init__(device_card_text(device, extended=extended), **kwargs)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Filter inputs for dashboard / mass config (parity with Streamlit device filters)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.widgets import Input, Label, Select
|
||||||
|
from textual.widgets._select import NULL as SELECT_NULL
|
||||||
|
|
||||||
|
from shelly_manager.core.mass_config import (
|
||||||
|
SETTINGS_FILTER_PRESETS,
|
||||||
|
STATUS_FILTER_PRESETS,
|
||||||
|
get_settings_preset,
|
||||||
|
get_status_preset,
|
||||||
|
)
|
||||||
|
from shelly_manager.core.models import DeviceFilter
|
||||||
|
|
||||||
|
|
||||||
|
class FilterBar(Vertical):
|
||||||
|
"""Generation, auth, text search, settings/status presets."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
FilterBar {
|
||||||
|
height: auto;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
FilterBar Horizontal {
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
FilterBar Input {
|
||||||
|
min-width: 14;
|
||||||
|
}
|
||||||
|
FilterBar .filter_label {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Horizontal():
|
||||||
|
yield Label("Gen:", classes="filter_label")
|
||||||
|
yield Select(
|
||||||
|
[("All", "all"), ("Gen1", "1"), ("Gen2+", "2")],
|
||||||
|
id="filter_gen",
|
||||||
|
value="all",
|
||||||
|
classes="filter_ctl",
|
||||||
|
)
|
||||||
|
yield Label("Online:", classes="filter_label")
|
||||||
|
yield Select(
|
||||||
|
[("any", "any"), ("yes", "yes"), ("no", "no")],
|
||||||
|
id="filter_online",
|
||||||
|
value="any",
|
||||||
|
classes="filter_ctl",
|
||||||
|
)
|
||||||
|
yield Label("Auth:", classes="filter_label")
|
||||||
|
yield Select(
|
||||||
|
[("any", "any"), ("yes", "yes"), ("no", "no")],
|
||||||
|
id="filter_auth",
|
||||||
|
value="any",
|
||||||
|
classes="filter_ctl",
|
||||||
|
)
|
||||||
|
with Horizontal():
|
||||||
|
yield Label("Search:", classes="filter_label")
|
||||||
|
yield Input(
|
||||||
|
placeholder="name / id / model substring…",
|
||||||
|
id="filter_name",
|
||||||
|
classes="filter_ctl",
|
||||||
|
)
|
||||||
|
with Horizontal():
|
||||||
|
yield Label("Model:", classes="filter_label")
|
||||||
|
yield Input(placeholder="model contains…", id="filter_model", classes="filter_ctl")
|
||||||
|
yield Label("FW:", classes="filter_label")
|
||||||
|
yield Input(placeholder="firmware…", id="filter_fw", classes="filter_ctl")
|
||||||
|
yield Label("IP:", classes="filter_label")
|
||||||
|
yield Input(placeholder="IP prefix…", id="filter_ip", classes="filter_ctl")
|
||||||
|
with Horizontal():
|
||||||
|
yield Label("MAC:", classes="filter_label")
|
||||||
|
yield Input(placeholder="exact MAC", id="filter_mac", classes="filter_ctl")
|
||||||
|
yield Label("Tags:", classes="filter_label")
|
||||||
|
yield Input(placeholder="t1, t2", id="filter_tags", classes="filter_ctl")
|
||||||
|
yield Label("Caps:", classes="filter_label")
|
||||||
|
yield Input(placeholder="relay, mqtt, …", id="filter_caps", classes="filter_ctl")
|
||||||
|
with Horizontal():
|
||||||
|
yield Label("Settings:", classes="filter_label")
|
||||||
|
yield Select(
|
||||||
|
[("—", "none")] + [(p.label, p.id) for p in SETTINGS_FILTER_PRESETS],
|
||||||
|
id="filter_set_preset",
|
||||||
|
value="none",
|
||||||
|
classes="filter_ctl",
|
||||||
|
)
|
||||||
|
yield Label("Status:", classes="filter_label")
|
||||||
|
yield Select(
|
||||||
|
[("—", "none")] + [(p.label, p.id) for p in STATUS_FILTER_PRESETS],
|
||||||
|
id="filter_stat_preset",
|
||||||
|
value="none",
|
||||||
|
classes="filter_ctl",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _str(self, wid: str) -> str:
|
||||||
|
w = self.query_one(f"#{wid}", Input)
|
||||||
|
return (w.value or "").strip()
|
||||||
|
|
||||||
|
def _sel_val(self, wid: str) -> str:
|
||||||
|
w = self.query_one(f"#{wid}", Select)
|
||||||
|
raw = w.value
|
||||||
|
if raw is None or raw is SELECT_NULL:
|
||||||
|
return "none"
|
||||||
|
return str(raw)
|
||||||
|
|
||||||
|
def build_filter(self) -> DeviceFilter:
|
||||||
|
gv = self._sel_val("filter_gen")
|
||||||
|
gens: list[int] | None = None
|
||||||
|
if gv == "1":
|
||||||
|
gens = [1]
|
||||||
|
elif gv == "2":
|
||||||
|
gens = [2, 3]
|
||||||
|
|
||||||
|
online_only: bool | None = None
|
||||||
|
o = self._sel_val("filter_online")
|
||||||
|
if o == "yes":
|
||||||
|
online_only = True
|
||||||
|
elif o == "no":
|
||||||
|
online_only = False
|
||||||
|
|
||||||
|
auth_required: bool | None = None
|
||||||
|
a = self._sel_val("filter_auth")
|
||||||
|
if a == "yes":
|
||||||
|
auth_required = True
|
||||||
|
elif a == "no":
|
||||||
|
auth_required = False
|
||||||
|
|
||||||
|
name = self._str("filter_name") or None
|
||||||
|
model_sub = self._str("filter_model") or None
|
||||||
|
fw = self._str("filter_fw") or None
|
||||||
|
ip_prefix = self._str("filter_ip") or None
|
||||||
|
mac = self._str("filter_mac") or None
|
||||||
|
|
||||||
|
tags_raw = self._str("filter_tags")
|
||||||
|
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] or None
|
||||||
|
|
||||||
|
caps_raw = self._str("filter_caps")
|
||||||
|
capabilities_any = [c.strip() for c in caps_raw.split(",") if c.strip()] or None
|
||||||
|
|
||||||
|
settings_path = None
|
||||||
|
settings_match = None
|
||||||
|
sp = self._sel_val("filter_set_preset")
|
||||||
|
if sp != "none":
|
||||||
|
pr = get_settings_preset(sp)
|
||||||
|
if pr:
|
||||||
|
settings_path = pr.settings_path
|
||||||
|
settings_match = pr.settings_match
|
||||||
|
|
||||||
|
status_path = None
|
||||||
|
status_match = None
|
||||||
|
stp = self._sel_val("filter_stat_preset")
|
||||||
|
if stp != "none":
|
||||||
|
sr = get_status_preset(stp)
|
||||||
|
if sr:
|
||||||
|
status_path = sr.status_path
|
||||||
|
status_match = sr.status_match
|
||||||
|
|
||||||
|
return DeviceFilter(
|
||||||
|
generations=gens,
|
||||||
|
online_only=online_only,
|
||||||
|
auth_required=auth_required,
|
||||||
|
name_contains=name,
|
||||||
|
model_contains=model_sub,
|
||||||
|
firmware_contains=fw,
|
||||||
|
ip_prefix=ip_prefix,
|
||||||
|
mac=mac,
|
||||||
|
tags=tags,
|
||||||
|
capabilities_any=capabilities_any,
|
||||||
|
settings_path=settings_path,
|
||||||
|
settings_match=settings_match,
|
||||||
|
status_path=status_path,
|
||||||
|
status_match=status_match,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Online/offline indicator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBadge(Static):
|
||||||
|
"""Small colored status line."""
|
||||||
|
|
||||||
|
def __init__(self, online: bool, **kwargs: object) -> None:
|
||||||
|
label = "[green]● online[/]" if online else "[red]● offline[/]"
|
||||||
|
super().__init__(label, **kwargs)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Core models and orchestration.
|
||||||
|
|
||||||
|
Avoid importing :class:`~shelly_manager.core.device_manager.DeviceManager` at package
|
||||||
|
import time: ``api.client`` imports ``core.discovery``, which loads this package, and
|
||||||
|
``DeviceManager`` imports ``api.client`` (circular import).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from shelly_manager.core.config import AppConfig
|
||||||
|
from shelly_manager.core.models import (
|
||||||
|
ConfigSnapshot,
|
||||||
|
DeviceFilter,
|
||||||
|
DiscoveredEndpoint,
|
||||||
|
ShellyDevice,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from shelly_manager.core.device_manager import DeviceManager as DeviceManager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppConfig",
|
||||||
|
"ConfigSnapshot",
|
||||||
|
"DeviceFilter",
|
||||||
|
"DiscoveredEndpoint",
|
||||||
|
"ShellyDevice",
|
||||||
|
"DeviceManager",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
if name == "DeviceManager":
|
||||||
|
from shelly_manager.core.device_manager import DeviceManager
|
||||||
|
|
||||||
|
return DeviceManager
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Application configuration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig(BaseModel):
|
||||||
|
"""Runtime configuration for CLI and UI."""
|
||||||
|
|
||||||
|
storage_backend: Literal["sqlite", "markdown"] = "sqlite"
|
||||||
|
sqlite_path: Path = Field(default=Path("./data/shelly_devices.db"))
|
||||||
|
markdown_dir: Path = Field(default=Path("./data/devices_md"))
|
||||||
|
poll_interval_sec: float = Field(default=30.0, ge=1.0)
|
||||||
|
subnet_scan_cidr: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional e.g. 192.168.1.0/24 for HTTP discovery scan",
|
||||||
|
)
|
||||||
|
discovery_http_timeout_sec: float = Field(
|
||||||
|
default=3.0,
|
||||||
|
ge=0.5,
|
||||||
|
le=30.0,
|
||||||
|
description="Timeout per GET /shelly during subnet scan and manual add-by-IP.",
|
||||||
|
)
|
||||||
|
mdns_timeout_sec: float = Field(default=5.0, ge=0.5)
|
||||||
|
subnet_concurrency: int = Field(default=64, ge=1, le=256)
|
||||||
|
auto_refresh: bool = Field(default=False)
|
||||||
|
#: Max stored config snapshots per device (before/after edits, mass ops); oldest trimmed.
|
||||||
|
config_snapshot_max_per_device: int = Field(default=10, ge=1, le=500)
|
||||||
|
#: After a Mass Config bulk RPC run, live-refresh all devices in the current filtered table.
|
||||||
|
mass_config_refresh_after_bulk: bool = Field(default=True)
|
||||||
|
#: How many recent config snapshots to list per device in Dashboard / Mass Config tables (0 = show "—").
|
||||||
|
inventory_versions_shown: int = Field(default=3, ge=0, le=50)
|
||||||
|
|
||||||
|
model_config = {"arbitrary_types_allowed": True}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Helpers for merging partial GetConfig updates (Gen2+ mass section apply)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Recursively merge *patch* into a copy of *base* (dict values merge; other values replace)."""
|
||||||
|
out = copy.deepcopy(base)
|
||||||
|
for k, v in patch.items():
|
||||||
|
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
|
||||||
|
out[k] = deep_merge_dict(out[k], v)
|
||||||
|
else:
|
||||||
|
out[k] = copy.deepcopy(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Curated top-level Shelly.GetConfig keys (Gen2+). Values must be JSON objects for Component.SetConfig.
|
||||||
|
SECTION_KEY_CHOICES: list[tuple[str, str]] = [
|
||||||
|
("mqtt", "MQTT — broker, TLS, client id, …"),
|
||||||
|
("wifi", "WiFi — AP + STA (shape varies by firmware)"),
|
||||||
|
("wifi_sta", "WiFi STA — credentials (if a separate top-level key)"),
|
||||||
|
("wifi_ap", "WiFi AP — soft-AP"),
|
||||||
|
("sntp", "SNTP / time — server, enable"),
|
||||||
|
("coiot", "CoIoT — UDP peer, update period"),
|
||||||
|
("cloud", "Shelly Cloud"),
|
||||||
|
("sys", "System — name, location, debug, …"),
|
||||||
|
("ble", "Bluetooth LE"),
|
||||||
|
("eth", "Ethernet"),
|
||||||
|
("ws", "WebSocket / RPC"),
|
||||||
|
("mdns", "mDNS"),
|
||||||
|
("__custom__", "Other — enter a custom top-level key below"),
|
||||||
|
]
|
||||||
|
|
||||||
|
__all__ = ["SECTION_KEY_CHOICES", "deep_merge_dict"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user