Ir al contenido

Dashboards y apps de datos

Pantallas llenas de números, tablas y listas: el tipo de herramienta interna, panel de administración o app multimedia que son muchos programas de escritorio. Los seis primeros son Bloques, pantallas completas escritas con la biblioteca de componentes que puedes copiar y adaptar; la página de cada bloque tiene su código completo y notas sobre qué cambiar. Cada captura sigue el tema de este sitio: cambia entre claro y oscuro arriba de la página para verlas en los dos.

Un panel de administración: navegación lateral, tarjetas de KPI con tendencias, un gráfico de ingresos, fuentes de tráfico y una tabla de pedidos recientes.

Un dashboard con tarjetas de KPI, un gráfico de ingresos y pedidos recientesUn dashboard con tarjetas de KPI, un gráfico de ingresos y pedidos recientes
  • Las tarjetas de KPI son ui.stat con un delta y una pequeña línea de trend. Los gráficos también son componentes listos: ui.bar_chart para los ingresos (su rango es un control ui.segmented) y ui.donut para las fuentes de tráfico, con una barra ui.progress para el objetivo del mes.
  • La barra lateral cambia de página cambiando un atributo del estado: mira Una app con varias vistas.
Windows, macOS y Linux
uv run --with pyzed-ui python examples/blocks/dashboard.py

El código: bloque Dashboard.

Una tabla de miembros de un equipo con búsqueda, filtros por estado, columnas ordenables, selección de filas, páginas y una acción de exportar.

Una tabla de miembros con filtros, selección y páginasUna tabla de miembros con filtros, selección y páginas
Windows, macOS y Linux
uv run --with pyzed-ui python examples/blocks/data_table.py

El código: bloque Data table. Para aprender las partes una a una (buscar, ordenar, seleccionar, mostrar los detalles), sigue Listas y tablas con datos, que construye esta pequeña biblioteca de libros:

Una biblioteca de libros: una tabla con búsqueda y orden, y los detalles del libro elegido

Un tablero de proyecto con columnas, tarjetas con etiquetas, responsables, progreso y botones para mover las tarjetas.

Un tablero kanban con tres columnas de tarjetasUn tablero kanban con tres columnas de tarjetas
Windows, macOS y Linux
uv run --with pyzed-ui python examples/blocks/kanban.py

El código: bloque Kanban.

Una mensajería con canales, mensajes directos, un hilo de mensajes y una caja para escribir.

Un chat de equipo con canales y una conversaciónUn chat de equipo con canales y una conversación
Windows, macOS y Linux
uv run --with pyzed-ui python examples/blocks/chat.py

El código: bloque Chat. Para un chat que recibe respuestas en streaming de un modelo de IA, mira el Chat con IA.

Una barra lateral con la biblioteca, una página de álbum con su lista de canciones y una barra de reproducción cuyo progreso avanza mientras suena una canción.

Un reproductor musical con un álbum y sus cancionesUn reproductor musical con un álbum y sus canciones
Windows, macOS y Linux
uv run --with pyzed-ui python examples/blocks/music_player.py

El código: bloque Music player.

La disposición de un editor de código: un árbol de proyecto, pestañas con migas de pan, una terminal, un panel de estructura y una barra de estado.

Un layout de IDE con árbol de proyecto, editor y terminalUn layout de IDE con árbol de proyecto, editor y terminal
Windows, macOS y Linux
uv run --with pyzed-ui python examples/blocks/file_explorer.py

El código: bloque File explorer. Un editor que funciona con la misma idea es el Editor de proyectos.

Una app de contactos sobre SQLAlchemy 2.0 con 20.000 filas en una tabla virtual. La búsqueda y el orden corren como consultas SQL en un hilo de trabajo (Command.perform), así que la ventana nunca espera a la base de datos.

La app de contactos: totales, un gráfico de ciudades, la tabla de 20.000 contactos y el panel de edición
Windows, macOS y Linux
cd examples/integrations/sqlalchemy_contacts
uv run main.py
Código completo: db.py y main.py
examples/integrations/sqlalchemy_contacts/db.py
"""The database: SQLAlchemy 2.0 models on SQLite, plus the queries the window runs."""
from __future__ import annotations
import random
from datetime import datetime
from pathlib import Path
from sqlalchemy import String, create_engine, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
DATABASE = Path(__file__).with_name("contacts.db")
engine = create_engine(f"sqlite:///{DATABASE}")
class Base(DeclarativeBase):
pass
class Contact(Base):
__tablename__ = "contacts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(80), index=True)
email: Mapped[str] = mapped_column(String(120), unique=True)
company: Mapped[str] = mapped_column(String(80), default="")
city: Mapped[str] = mapped_column(String(60), default="")
created: Mapped[datetime] = mapped_column(default=datetime.now)
# Sorting by a column title of the table is an ORDER BY on this column.
COLUMNS = {"Name": Contact.name, "Email": Contact.email, "Company": Contact.company, "City": Contact.city}
def setup(rows: int = 20_000) -> None:
"""Creates the table and fills it with made-up contacts the first time."""
Base.metadata.create_all(engine)
with Session(engine) as session:
if session.scalar(select(func.count()).select_from(Contact)):
return
rng = random.Random(7)
first = ["Ada", "Alan", "Grace", "Linus", "Guido", "Margaret", "Ken", "Barbara", "Dennis", "Radia",
"Tim", "Frances", "Bjarne", "Anita", "John", "Hedy", "Yukihiro", "Sophie", "Brendan", "Katherine"]
last = ["Lovelace", "Turing", "Hopper", "Torvalds", "van Rossum", "Hamilton", "Thompson", "Liskov",
"Ritchie", "Perlman", "Berners-Lee", "Allen", "Stroustrup", "Borg", "McCarthy", "Lamarr",
"Matsumoto", "Wilson", "Eich", "Johnson"]
companies = ["Acme", "Globex", "Initech", "Umbrella", "Hooli", "Stark", "Wayne", "Tyrell", "Cyberdyne", "Aperture"]
cities = ["Buenos Aires", "Madrid", "Mexico City", "Bogotá", "Lima", "Santiago", "Montevideo", "Barcelona",
"Berlin", "Tokyo", "Toronto", "Lisbon"]
weights = [12, 10, 9, 7, 6, 5, 4, 4, 3, 3, 2, 2]
session.add_all(
Contact(name=f"{rng.choice(first)} {rng.choice(last)}", email=f"user{i}@example.com",
company=rng.choice(companies), city=rng.choices(cities, weights)[0])
for i in range(rows)
)
session.commit()
def search(text: str, sort: str, descending: bool) -> list[Contact]:
"""Contacts whose name, email or company contains ``text``, sorted in SQL."""
column = COLUMNS[sort]
query = select(Contact).order_by(column.desc() if descending else column, Contact.id)
if text:
pattern = f"%{text}%"
query = query.where(or_(Contact.name.ilike(pattern), Contact.email.ilike(pattern),
Contact.company.ilike(pattern)))
with Session(engine) as session:
return list(session.scalars(query)) # plain objects keep their loaded values after the session closes
def stats() -> dict:
"""Totals and the five cities with more contacts, for the header."""
with Session(engine) as session:
total = session.scalar(select(func.count()).select_from(Contact))
companies = session.scalar(select(func.count(func.distinct(Contact.company))))
cities = session.execute(select(Contact.city, func.count()).group_by(Contact.city)
.order_by(func.count().desc()).limit(5)).all()
return {"total": total, "companies": companies, "cities": [(city, count) for city, count in cities]}
def save(contact_id: int | None, values: dict) -> Contact | dict:
"""Inserts or updates a contact. Returns it, or ``{field: message}`` when it can't be saved."""
errors = {}
if not values["name"].strip():
errors["name"] = "A contact needs a name"
if "@" not in values["email"]:
errors["email"] = "Not an email address"
if errors:
return errors
with Session(engine, expire_on_commit=False) as session:
contact = session.get(Contact, contact_id) if contact_id else Contact()
for key, value in values.items():
setattr(contact, key, value.strip())
session.add(contact)
try:
session.commit()
except IntegrityError:
return {"email": "Another contact already uses this email"}
return contact
def delete(contact_id: int) -> None:
with Session(engine) as session:
contact = session.get(Contact, contact_id)
if contact is not None:
session.delete(contact)
session.commit()
examples/integrations/sqlalchemy_contacts/main.py
"""A contacts manager on SQLAlchemy 2.0: 20,000 rows in a virtual table, searched and sorted in SQL.
uv run main.py
The database (contacts.db) is created next to this file on the first run.
Walkthrough: https://albakore.github.io/pyzed/integrations/sqlalchemy/
"""
from __future__ import annotations
import pyzed as pz
from pyzed import Command, ui
import db
FIELDS = [("name", "Name", "Ada Lovelace"), ("email", "Email", "ada@example.com"),
("company", "Company", "Analytical Engines"), ("city", "City", "London")]
class Contacts(pz.App):
title = "Contacts · SQLAlchemy"
size = (1180, 720)
def __init__(self):
self.rows: list[db.Contact] = []
self.stats = {"total": 0, "companies": 0, "cities": []}
self.query = ""
self.sort, self.descending = "Name", False
self.loading = True
self.editing = None # a Contact, "new" or None
self.draft: dict[str, str] = {}
self.errors: dict[str, str] = {}
# -- database work runs in a thread with Command.perform ------------------------------
def on_start(self):
return Command.perform(db.setup, lambda _: self.reload())
def reload(self):
self.loading = True
query, sort, descending = self.query, self.sort, self.descending
return [
Command.perform(lambda: db.search(query, sort, descending), lambda rows: self.loaded(query, rows)),
Command.perform(db.stats, lambda stats: setattr(self, "stats", stats)),
]
def loaded(self, query: str, rows: list[db.Contact]):
if query == self.query: # a newer search is on its way: keep waiting for it
self.rows, self.loading = rows, False
def search(self, text: str):
self.query = text
return self.reload()
def sort_by(self, title: str):
self.descending = not self.descending if title == self.sort else False
self.sort = title
return self.reload()
# -- the edit panel ------------------------------------------------------------------
def open(self, contact):
self.editing, self.errors = contact, {}
self.draft = {key: "" if contact == "new" else getattr(contact, key) for key, _, _ in FIELDS}
def save(self):
contact_id = None if self.editing == "new" else self.editing.id
values = dict(self.draft)
return Command.perform(lambda: db.save(contact_id, values), self.saved)
def saved(self, result):
if isinstance(result, dict):
self.errors = result
return None
self.editing = None
return self.reload()
def delete(self):
contact_id = self.editing.id
self.editing = None
return Command.perform(lambda: db.delete(contact_id), lambda _: self.reload())
def on_key(self, combo):
if combo == "escape":
self.editing = None
elif combo == "ctrl-n":
self.open("new")
# -- view ----------------------------------------------------------------------------
def header(self):
cities = self.stats["cities"]
return pz.row([
ui.stat("Contacts", f"{self.stats['total']:,}"),
ui.stat("Companies", self.stats["companies"]),
ui.card(pz.column([ui.muted("Top cities", size="sm"),
ui.bar_chart([count for _, count in cities], labels=[c.split()[0] for c, _ in cities],
width=360, height=90)], spacing=6)),
], spacing=12)
def panel(self, t):
if self.editing is None:
return None
rows = [ui.field(title, ui.input(self.draft[key], lambda v, k=key: self.draft.__setitem__(k, v),
placeholder=hint, width=220),
error=self.errors.get(key))
for key, title, hint in FIELDS]
new = self.editing == "new"
return pz.container(
pz.column([
pz.row([ui.heading("New contact" if new else "Edit contact", level=3), pz.hspace(),
ui.icon_button("x", lambda: setattr(self, "editing", None), tooltip="Close (Esc)")],
align="center"),
*rows,
pz.row([None if new else ui.button("Delete", self.delete, icon="trash-2", variant="ghost", tint="error"),
pz.hspace(), ui.button("Save", self.save, icon="check", variant="solid")], spacing=8),
], spacing=14),
padding=16, width=420, height="fill", bg=t.panel,
)
def view(self):
t = self.resolved_theme()
columns = [
ui.Column("Name", "name", width="fill:3"),
ui.Column("Email", "email", width="fill:3"),
ui.Column("Company", "company", width="fill:2"),
ui.Column("City", "city", width="fill:2"),
]
toolbar = pz.row([
ui.search_input(self.query, self.search, placeholder="Search by name, email or company…", width=360),
ui.spinner() if self.loading else ui.muted(f"{len(self.rows):,} found", size="sm"),
pz.hspace(),
ui.button("New contact", lambda: self.open("new"), icon="user-plus", keys="ctrl-n"),
], spacing=10, align="center")
# Rows come sorted from SQL; sort_by only draws the arrow (re-sorting a sorted list is cheap).
table = ui.table(columns, self.rows, sort_by=self.sort, descending=self.descending, on_sort=self.sort_by,
on_row_press=self.open, selected=self.editing, striped=True, height="fill")
main = pz.column([self.header(), toolbar, table], spacing=14, padding=16)
return pz.container(pz.row([main, self.panel(t)]), width="fill", height="fill", bg=t.editor)
if __name__ == "__main__":
pz.run(Contacts)

La explicación está en SQLAlchemy. Sin ninguna biblioteca, el tutorial Conectar un backend construye una app de contactos más pequeña sobre el módulo sqlite3 que viene con Python, con una clase repositorio, un spinner y errores amables:

Una lista de contactos cargada desde SQLite, con un buscador y un formulario para añadir
  • Datos en vivo: un panel de cotizaciones alimentado por un generador async.
  • Centro de control de cristal: un dashboard con tarjetas de cristal esmerilado sobre un shader en movimiento.
  • Integraciones: FastAPI, Django, SQLModel, httpx y más, cada una con una app de escritorio.