Organizing your dashboard
- TypeScript
- JavaScript
- Python
We now having a working Interval action! In this section we'll add additional structure and organization to our Interval dashboard, adding some hierarchical navigation and grouping related actions within a page.
info
The Pages API is available on SDK versions v0.35.0 and higher.
Creating a page
By default all the Interval actions you create will live at the top-level in your dashboard. We provide ways to filter and search for the tool you're looking for, but you'll probably want to create pages to better organize your actions however you see fit.
To create a page, create a subdirectory in your routes
directory, and create
an index.ts
file inside it.
import { Page } from "@interval/sdk";
export default new Page({
name: "Refunds",
});
The directory name configures the URL path for the page, just as file names do with actions.
To create a page, create a subdirectory in your routes
directory, and create
an index.ts
file inside it.
const { Page } = require("@interval/sdk");
module.exports = new Page({
name: "Refunds",
});
The directory name configures the URL path for the page, just as file names do with actions.
To create a page, define a Page
class and assign a path for it by adding it to your routes. The provided slug configures the URL path for the page, just as it does with actions, and must be unique at that level of navigation.
import os
from interval_sdk import Interval, IO, ctx_var, Page
from payments import get_charges, refund_charge
interval = Interval(
os.environ.get("INTERVAL_KEY"),
)
refunds_page = Page(name="Refunds")
interval.routes.add("refunds", refunds_page)
@interval.action
async def refund_user(io: IO):
customer_email = await io.input.email("Email of the customer to refund:")
print("Email:", customer_email)
charges = await get_charges(customer_email)
charges_to_refund = await io.select.table(
"Select one or more charges to refund",
data=charges,
)
ctx = ctx_var.get()
await ctx.loading.start(
title="Refunding charges",
# Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
items_in_queue=len(charges_to_refund),
)
for charge in charges_to_refund:
await refund_charge(charge.id)
await ctx.loading.complete_one()
# Values returned from actions are automatically stored with Interval transaction logs
return { "charges_to_refund": len(charges_to_refund) }
# Establishes a persistent connection between Interval and your app.
interval.listen()
Once you've defined your page, if you revisit the Interval dashboard, you'll now see top-level navigation for reaching your page.

The new directory acts as a "sub-folder" for grouping actions. Simply move action files into the subdirectory, just as you would do at the top level. Here we'll move our refund tool to our new "Refunds" page, and also provide an explicit name
for a cleaner display:
import { Action, io, ctx } from "@interval/sdk";
import { getCharges, refundCharge } from "./payments";
export default new Action({
name: "Create refund",
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
});
The new directory acts as a "sub-folder" for grouping actions. Simply move action files into the subdirectory, just as you would do at the top level. Here we'll move our refund tool to our new "Refunds" page, and also provide an explicit name
for a cleaner display:
const { Action, io, ctx } = require("@interval/sdk");
const { getCharges, refundCharge } = require("./payments");
module.exports = new Action({
name: "Create refund",
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
});
The created page can also act as a "sub-folder" for grouping actions. Simply use the @page.action
decorator instead of @interval.action
. Here we'll move our refund tool to our new "Refunds" page, and also provide an explicit name
for a cleaner display:
import os
from interval_sdk import Interval, IO, ctx_var, Page
from payments import get_charges, refund_charge
interval = Interval(
os.environ.get("INTERVAL_KEY"),
)
refunds_page = Page(name="Refunds")
interval.routes.add("refunds", refunds_page)
@refunds_page.action(name="Refund user")
async def refund_user(io: IO):
customer_email = await io.input.email("Email of the customer to refund:")
print("Email:", customer_email)
charges = await get_charges(customer_email)
charges_to_refund = await io.select.table(
"Select one or more charges to refund",
data=charges,
)
ctx = ctx_var.get()
await ctx.loading.start(
title="Refunding charges",
# Because we specified `items_in_queue`, Interval will render a progress bar versus an indeterminate loading indicator.
items_in_queue=len(charges_to_refund),
)
for charge in charges_to_refund:
await refund_charge(charge.id)
await ctx.loading.complete_one()
# Values returned from actions are automatically stored with Interval transaction logs
return { "charges_to_refund": len(charges_to_refund) }
# Establishes a persistent connection between Interval and your app.
interval.listen()

You can repeat this process to add as many pages as you like to your dashboard, grouping related tools and information together on separate pages to help organize your dashboard and make it easier to navigate. Pages can also be nested arbitrarily!
Rendering content
Currently our page simply acts as a sub-folder for grouping actions. You can also add content to each page to provide more information and context for the tools and data on that page. This could include text descriptions, metadata cards, tables, images, code snippets, and any other content supported via Interval display
I/O methods.
We'll flesh out our new page with a description, stats on the number of recent refunds, and a table listing the refunds we've made.
To accomplish this, we'll define a handler
function when instantiating the Page
class. This function should load any required data and return a Layout
class that defines the content of the page.
import { Page, Layout, io, ctx } from "@interval/sdk";
import { getRefunds } from "./payments";
export default new Page({
name: "Refunds",
handler: async () => {
const refunds = await getRefunds();
return new Layout({
title: "Refunds",
description: "View and create refunds for our customers.",
children: [
io.display.metadata("", {
layout: "card",
data: [
{
label: "Total refunds",
value: refunds.length,
},
],
}),
io.display.table("Refunds", {
data: refunds,
}),
],
});
},
});
To accomplish this, we'll define a handler
function when instantiating the Page
class. This function should load any required data and return a Layout
class that defines the content of the page.
const { Page, Layout, io, ctx } = require("@interval/sdk");
const { getRefunds } = require("./payments");
module.exports = new Page({
name: "Refunds",
handler: async () => {
const refunds = await getRefunds();
return new Layout({
title: "Refunds",
description: "View and create refunds for our customers.",
children: [
io.display.metadata("", {
layout: "card",
data: [
{
label: "Total refunds",
value: refunds.length,
},
],
}),
io.display.table("Refunds", {
data: refunds,
}),
],
});
},
});
To accomplish this, we'll define a handler function using the handle
decorator on the Page
class. This function should load any required data and return a Layout
class that defines the content of the page.
import os
from interval_sdk import Interval, IO, ctx_var, Page, Layout
from payments import get_charges, refund_charge, get_refunds
interval = Interval(
os.environ.get("INTERVAL_KEY"),
)
refunds_page = Page(name="Refunds")
interval.routes.add("refunds", refunds_page)
@refunds_page.handle(name="Refund user")
async def handler(display: IO.Display):
refunds = await get_refunds()
return Layout(
title="Refunds",
description="View and create refunds for our customers.",
children=[
display.metadata(
"",
layout="card",
data=[
{"label": "Total refunds", "value": len(refunds)},
],
),
display.table("Refunds", data=refunds),
],
)
@refunds_page.action(name="Refund user"
async def refund_user(io: IO):
customer_email = await io.input.email("Email of the customer to refund:")
print("Email:", customer_email)
charges = await get_charges(customer_email)
charges_to_refund = await io.select.table(
"Select one or more charges to refund",
data=charges,
)
ctx = ctx_var.get()
await ctx.loading.start(
title="Refunding charges",
# Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
items_in_queue=len(charges_to_refund),
)
for charge in charges_to_refund:
await refund_charge(charge.id)
await ctx.loading.complete_one()
# Values returned from actions are automatically stored with Interval transaction logs
return { "charges_to_refund": len(charges_to_refund) }
# Establishes a persistent connection between Interval and your app.
interval.listen()
Here's the update to payments.ts
with the new get_refunds
function we're calling above.
import time
import random
import string
import asyncio
from datetime import datetime
charges = {}
refunds = []
async def get_customer_charges(customer_email):
if not customer_email:
raise Exception("A customer id is required")
if customer_email not in charges:
charges[customer_email] = [
create_charge(customer_email) for i in range(random.randint(1, 5))
]
# Replaces Stripe SDK call
# customer = stripe.Customer.list(limit=1, email=customer_email)["data"][0]
# return stripe.Charge.list(customer=customer.id)
customer_charges = charges[customer_email]
return [format_charge(charge) for charge in customer_charges]
async def refund_charge(charge_id):
await asyncio.sleep(1)
if not charge_id:
raise Exception("A charge id is required")
charge = next(
charge
for customer_id in charges
for charge in charges[customer_id]
if charge["id"] == charge_id
)
if not charge:
raise Exception("Charge not found")
if charge["refunded"]:
raise Exception("Charge already refunded")
refund = {
"id": f"ch_{random_string()}",
"amount": charge["amount"],
"charge": charge["id"],
"currency": charge["currency"],
"created": round(time.time()),
}
charge_index = next(
i
for i in range(len(charges[charge["customer"]]))
if charges[charge["customer"]][i]["id"] == charge_id
)
# Replaces Stripe SDK call
# stripe.Refund.create(charge=charge_id)
charge["refunded"] = True
charges[charge["customer"]][charge_index] = charge
refunds.append(refund)
return refund
def create_charge(customer_id):
return {
"id": f"ch_{random_string()}",
"amount": random.randint(100, 10000),
"currency": "usd",
"description": random_string(),
"created": round(time.time()),
"customer": customer_id,
"refunded": False,
}
def format_charge(charge):
return {
"id": charge["id"],
"amount": "${:,.2f}".format(charge["amount"] / 100),
"description": charge["description"],
"created": datetime.utcfromtimestamp(charge["created"]).strftime(
"%Y-%m-%d %H:%M:%S"
),
"isRefunded": charge["refunded"],
}
async def get_refunds():
# Replaces Stripe SDK call
# return stripe.Refund.list()
return [format_refund(refund) for refund in refunds]
def format_refund(refund):
return {
"id": refund["id"],
"charge": refund["charge"],
"amount": "${:,.2f}".format(refund["amount"] / 100),
"currency": refund["currency"],
"created": datetime.utcfromtimestamp(refund["created"]).strftime(
"%Y-%m-%d %H:%M:%S"
),
}
def random_string(length=10):
return "".join(
random.choice(string.ascii_lowercase + string.digits) for i in range(length)
)

Adding menu items
While actions nested within a page provide organizational structure within your dashboard, you can also customize the UI of pages to provide quick access to useful actions via the menuItems
property.
menuItems
add easy-to-access buttons at the top of any page that can link to any actions within your Interval dashboard or to external sites (menuItems
elements have the same signature as Interval links).
import { Page, Layout, io, ctx } from "@interval/sdk";
import { getRefunds } from "./payments";
export default new Page({
name: "Refunds",
handler: async () => {
const refunds = await getRefunds();
return new Layout({
title: "Refunds",
description: "View and create refunds for our customers.",
menuItems: [
{
label: "Create refund",
route: "refunds/refund_user",
},
],
children: [
io.display.metadata("", {
layout: "card",
data: [
{
label: "Total refunds",
value: refunds.length,
},
],
}),
io.display.table("Refunds", {
data: refunds,
}),
],
});
},
});
For actions that are set as menu items, you can avoid showing a duplicate link in the page sidebar by setting the unlisted
property on the action definition.
import { Action, io, ctx } from "@interval/sdk";
import { getCharges, refundCharge } from "./payments";
export default new Action({
name: "Create refund",
unlisted: true,
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
});
While actions nested within a page provide organizational structure within your dashboard, you can also customize the UI of pages to provide quick access to useful actions via the menuItems
property.
menuItems
add easy-to-access buttons at the top of any page that can link to any actions within your Interval dashboard or to external sites (menuItems
elements have the same signature as Interval links).
const { Page, Layout, io, ctx } = require("@interval/sdk");
const { getRefunds } = require("./payments");
module.exports = new Page({
name: "Refunds",
handler: async () => {
const refunds = await getRefunds();
return new Layout({
title: "Refunds",
description: "View and create refunds for our customers.",
menuItems: [
{
label: "Create refund",
route: "refunds/refund_user",
},
],
children: [
io.display.metadata("", {
layout: "card",
data: [
{
label: "Total refunds",
value: refunds.length,
},
],
}),
io.display.table("Refunds", {
data: refunds,
}),
],
});
},
});
For actions that are set as menu items, you can avoid showing a duplicate link in the page sidebar by setting the unlisted
property on the action definition.
const { Action, io, ctx } = require("@interval/sdk");
const { getCharges, refundCharge } = require("./payments");
module.exports = new Action({
name: "Create refund",
unlisted: true,
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
});
While actions nested within a page provide organizational structure within your dashboard, you can also customize the UI of pages to provide quick access to useful actions via the menu_items
property.
menu_items
add easy-to-access buttons at the top of any page that can link to any actions within your Interval dashboard or to external sites (menu_items
elements have the same signature as Interval links).
For actions that are set as menu items, you can avoid showing a duplicate link in the page sidebar by setting the unlisted
property on the action definition.
import os
from interval_sdk import Interval, IO, ctx_var, Page
from payments import get_charges, refund_charge, get_refunds
interval = Interval(
os.environ.get("INTERVAL_KEY"),
)
refunds_page = Page(name="Refunds")
interval.routes.add("refunds", refunds_page)
@refunds_page.handle(name="Refund user", unlisted=True)
async def handler(display: IO.Display):
refunds = await get_refunds()
return Layout(
title="Refunds",
description="View and create refunds for our customers.",
menu_items=[
{"label": "Create refund", "route": "refunds/refund_user"},
],
children=[
display.metadata(
"",
layout="card",
data=[
{"label": "Total refunds", "value": len(refunds)},
],
),
display.table("Refunds", data=refunds),
],
)
@refunds_page.action(name="Refund user"
async def refund_user(io: IO):
customer_email = await io.input.email("Email of the customer to refund:")
print("Email:", customer_email)
charges = await get_charges(customer_email)
charges_to_refund = await io.select.table(
"Select one or more charges to refund",
data=charges,
)
ctx = ctx_var.get()
await ctx.loading.start(
title="Refunding charges",
# Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
items_in_queue=len(charges_to_refund),
)
for charge in charges_to_refund:
await refund_charge(charge.id)
await ctx.loading.complete_one()
# Values returned from actions are automatically stored with Interval transaction logs
return { "charges_to_refund": len(charges_to_refund) }
# Establishes a persistent connection between Interval and your app.
interval.listen()

✅ Recap
In Part 2, we extended our Interval dashboard by creating our first page to group actions and render additional content.
- Pages can be created by instantiating the
Page
class alongside actions. - To render content on a page, define the
handler
function and utilize I/Odisplay
methods. - Actions can be nested beneath pages in the route hierarchy and linked to via
menuItems
.
- Pages can be created by instantiating the
Page
class alongside actions. - To render content on a page, define the
handler
function and utilize I/Odisplay
methods. - Actions can be nested beneath pages in the route hierarchy and linked to via
menuItems
.
- Pages can be created by instantiating the
Page
class alongside actions. - To render content on a page, define the
handler
function and utilize I/Odisplay
methods. - Actions can be nested beneath pages in the route hierarchy and linked to via
menu_items
.