Build a Workflow Status Dashboard

Build a multi-page Vue 3 app with reactive filtering and Pinia state.

Introduction

30 Second Summary

When you run automated workflows, you need to know what succeeded, what failed, and what's still in progress. Without a dashboard, you're left checking logs one at a time.

In this project, you will build a Workflow Status Dashboard using Vue 3 that displays workflow executions with real-time search, status filtering, and detail pages. You'll use the same frontend patterns (Composition API, Pinia stores, Vue Router) that power the Kestra open-source UI.

What You'll Build

Picture opening your browser, seeing six workflow cards with colour-coded status badges, typing a name into the search bar and watching the list narrow instantly, then clicking a card to land on its full detail page.

By the end of this project, you'll have:

  • A live dashboard where you can search workflows by name and watch results update character by character as you type.
  • A set of status filter buttons that instantly narrow the list to only success, failed, or running workflows.
  • A detail page you can click into from any workflow card, showing the full execution info and a back link to the dashboard.
  • Secret Mission: Add a "trigger workflow" feature that lets you create new executions from a form and see them appear on the dashboard in real-time.

Are there any prerequisites?

You need basic HTML and JavaScript knowledge. No CSS or framework experience is required. Step 1 walks you through installing everything else you need.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Scaffold Your Vue 3 Project

Before you can build any features, you need a working Vue project with a development server that shows changes in real-time.

In this step, you will scaffold a fresh Vue 3 project using the official create-vue tool. This gives you a pre-configured project powered by Vite, a fast build tool that refreshes the browser instantly when you save a file.

In this step, get ready to:
  • Scaffold a new Vue 3 project with create-vue.
  • Install dependencies and start the development server.
  • Verify the Vue welcome page loads in your browser.
Run the Vue scaffolding command

The create-vue tool is an interactive wizard that asks you questions about your project and generates all the starter files for you.

  • Switch to your terminal and move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Vue project by running this command:
npm create vue@latest

What will the wizard ask?

The wizard first asks for your project name, then asks about TypeScript, then presents a list of features to select. Here is what to answer for each prompt:

  • Project name: type workflow-dashboard and press Enter.
  • TypeScript: select No.
  • JSX Support: select No.
  • Vue Router: select Yes.
  • Pinia: select Yes.
  • Vitest: select No.
  • End-to-End Testing: select No.
  • ESLint: select No.
  • For any other prompts (Prettier, DevTools, barebones): select No.
  • Confirm the project was created by running:
ls

You should see workflow-dashboard listed in the output.

Don't see the folder?

  • Make sure you typed workflow-dashboard as the project name when prompted. If you accepted the default name, the folder may have a different name.
  • Check that you ran cd ~/Desktop before running the scaffolding command.
  • help me troubleshoot why my create-vue command did not generate a project folder
Install dependencies and start the dev server

The scaffolded project is just a set of files. You need to install the packages it depends on, then start the development server so you can see it in the browser.

  • Enter the project folder and install its dependencies by running these commands:
cd workflow-dashboard
npm install

You will see npm downloading packages. When it finishes, you will see a summary of packages added.

  • Start the development server by running:
npm run dev

What does this command do?

This starts the Vite development server. It takes over your terminal and shows you a URL where your app is running. The server watches your files and refreshes the browser automatically whenever you save a change.

Your terminal will display a local URL (http://localhost:5173). Keep this terminal open for the rest of the project.

If you need to stop the server later, press Ctrl+C in the terminal.

Server not starting?

  • Make sure you ran npm install first. The dev server cannot start without installed packages.
  • Check that you are inside the workflow-dashboard folder. Run pwd to confirm your current directory.
  • help me figure out why npm run dev is not working in my Vue project
Open the project in your browser and VS Code

Before you open the browser, what do you think you will see at the URL the server printed? Take a guess.

  • Open your browser and navigate to http://localhost:5173.

You should see the default Vue welcome page with the green Vue logo and links to the documentation. This confirms your project is running.

  • Switch back to VS Code.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the workflow-dashboard folder, then click Open.

In the Explorer sidebar on the left, expand the src folder. You should see App.vue, the router/ folder, and the stores/ folder. These are the key parts of your project structure.

What are these folders for?

  • App.vue is the root component that wraps your entire application.
  • router/ contains the navigation configuration. It decides which page to show based on the URL.
  • stores/ is where Pinia stores live. Stores hold shared data that multiple pages can read from.

Don't see the Vue welcome page?

  • Check that your terminal still shows the dev server running. If it stopped, run npm run dev again from inside the workflow-dashboard folder.
  • Make sure you are visiting http://localhost:5173 (not https, and not a different port number).
  • help me see the Vue welcome page in my browser

Your Vue project is scaffolded, the dev server is live, and your editor is ready. Next up, you will replace the default boilerplate with your own workflow dashboard components.

Display Workflow Data with Reactive Templates

Right now your browser shows the default Vue welcome page. That is just scaffolding boilerplate with no connection to your project.

In this step, you will replace it with your own data and components. Vue's reactive system (ref) and template directives (v-for, v-bind) let you display dynamic data that updates automatically when it changes.

In this step, get ready to:
  • Create a mock data file with workflow execution objects.
  • Build a WorkflowCard component that renders each workflow's details.
  • Wire the data and component into your home view to display 6 workflow cards.
Create your workflow data file

Your dashboard needs data to display. In a real app this would come from an API, but for now you will create a local file with mock workflow executions.

  • In the VS Code Explorer sidebar, right-click the src folder and select New Folder.
  • Name the folder data and press Enter.
  • Right-click the new src/data folder and select New File.
  • Name the file workflows.js and press Enter.
  • Paste the first three workflow objects into workflows.js:
export const workflowData = [
  {
    id: 1,
    name: 'etl-daily-import',
    namespace: 'production.data',
    status: 'success',
    duration: '2m 34s',
    startedAt: '2026-07-14 08:00:00'
  },
  {
    id: 2,
    name: 'send-weekly-report',
    namespace: 'production.notifications',
    status: 'failed',
    duration: '0m 12s',
    startedAt: '2026-07-14 09:15:00'
  },
  {
    id: 3,
    name: 'backup-database',
    namespace: 'production.maintenance',
    status: 'running',
    duration: '5m 01s',
    startedAt: '2026-07-14 10:30:00'
  },
  • Immediately below the third object, paste the remaining three workflows:
  {
    id: 4,
    name: 'sync-user-data',
    namespace: 'staging.data',
    status: 'success',
    duration: '1m 48s',
    startedAt: '2026-07-14 07:45:00'
  },
  {
    id: 5,
    name: 'deploy-frontend',
    namespace: 'production.ci',
    status: 'success',
    duration: '3m 22s',
    startedAt: '2026-07-14 06:00:00'
  },
  {
    id: 6,
    name: 'generate-invoices',
    namespace: 'production.billing',
    status: 'failed',
    duration: '0m 45s',
    startedAt: '2026-07-14 11:00:00'
  }
]

What does this data represent?

Each object represents one workflow execution. The status field can be success, failed, or running. You will use this value later to colour-code the status badges.

The namespace groups workflows by team or environment, just like in Kestra's execution view.

  • Save workflows.js.
  • Confirm the file appears in the VS Code Explorer under src/data/workflows.js.
Build the WorkflowCard component

A Vue single-file component bundles its template, logic, and styles into one .vue file. You will create a WorkflowCard that accepts a workflow object as a prop and renders its details.

  • In the VS Code Explorer, right-click the src/components folder and select New File.
  • Name it WorkflowCard.vue and press Enter.
  • Paste the template and script for the component:
<template>
  <router-link :to="`/workflow/${workflow.id}`" class="workflow-card">
    <div class="workflow-header">
      <h3>{{ workflow.name }}</h3>
      <span :class="['status-badge', `status-${workflow.status}`]">
        {{ workflow.status }}
      </span>
    </div>
    <p class="workflow-namespace">{{ workflow.namespace }}</p>
    <div class="workflow-meta">
      <span>Duration: {{ workflow.duration }}</span>
      <span>Started: {{ workflow.startedAt }}</span>
    </div>
  </router-link>
</template>

<script setup>
defineProps({
  workflow: {
    type: Object,
    required: true
  }
})
</script>

What does this component do?

  • defineProps declares that this component expects a workflow object from its parent.
  • :class (short for v-bind:class) dynamically assigns a CSS class based on the workflow's status. A "success" workflow gets the class status-success.
  • The <router-link> wraps the card so clicking it will navigate to a detail page later.
  • Now replace the contents of src/views/HomeView.vue with this code that imports your data and renders WorkflowCard components:
<template>
  <div class="home">
    <h1>Workflow Dashboard</h1>
    <div class="workflow-list">
      <WorkflowCard
        v-for="workflow in workflows"
        :key="workflow.id"
        :workflow="workflow"
      />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { workflowData } from '../data/workflows.js'
import WorkflowCard from '../components/WorkflowCard.vue'

const workflows = ref(workflowData)
</script>

What is ref() doing here?

ref() wraps your array in a reactive container. When the data inside changes, Vue automatically re-renders the template. This is the foundation of Vue's Composition API reactivity system.

v-for="workflow in workflows" loops through the array and renders one WorkflowCard for each entry. The :key tells Vue how to track each item efficiently during re-renders.

  • Save both WorkflowCard.vue and HomeView.vue.
  • Switch to your browser at http://localhost:5173. The page should auto-refresh.

You should see "Workflow Dashboard" as a heading and 6 entries listed below it. The text shows each workflow's name, status, namespace, duration, and start time. They are unstyled for now.

Not seeing your workflows?

  • Check that your data file is saved at exactly src/data/workflows.js. The import path in HomeView must match.
  • Make sure WorkflowCard.vue is inside src/components/ (not src/ directly).
  • Look at your terminal for any red error text. A typo in a file name or import path shows up there.
  • help me debug why my WorkflowCard is not rendering
Add styles to the WorkflowCard

The workflow entries show up, but they look like plain text links. Scoped CSS will give each card a border, layout, and colour-coded status badges.

  • Open src/components/WorkflowCard.vue and add this <style scoped> section below the closing </script> tag. Start with the card layout rules:
<style scoped>
.workflow-card {
  display: block;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  text-decoration: none;
  color: inherit;
  transition: box-shadow 0.2s;
}

.workflow-card:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.workflow-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.workflow-header h3 {
  margin: 0;
  font-size: 16px;
}
  • Directly below the last rule, add the status badge and colour classes:
.status-badge {
  padding: 4px 10px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
}

.status-success {
  background-color: #d4edda;
  color: #155724;
}

.status-failed {
  background-color: #f8d7da;
  color: #721c24;
}

.status-running {
  background-color: #cce5ff;
  color: #004085;
}
  • Complete the style block with the metadata rules and closing tag:
.workflow-namespace {
  font-size: 13px;
  color: #666;
  margin: 0 0 8px 0;
}

.workflow-meta {
  display: flex;
  gap: 16px;
  font-size: 12px;
  color: #888;
}
</style>

How does v-bind:class pick the right colour?

In the template, :class="['status-badge', `status-${workflow.status}`]" builds a class string like status-badge status-success. The CSS you just added targets each status-* class with a different background colour.

Vue re-evaluates this binding whenever the data changes, so the badge always reflects the current status.

  • Save WorkflowCard.vue and switch to your browser.

Each workflow now appears as a card with a border, the name on the left, and a colour-coded status badge on the right. Green for success, red for failed, blue for running.

Cards still look unstyled?

  • Make sure the <style scoped> block is placed after </script>, not inside it.
  • Check that you included the closing </style> tag at the very end of the file.
  • Verify all three CSS sections are pasted in order with no gaps or duplicate braces between them.
  • help me debug why my WorkflowCard styles are not applying

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: src/data/workflows.js, src/components/WorkflowCard.vue, and src/views/HomeView.vue.

ⓧ I'd like to double check the full code

Your WorkflowCard.vue and workflows.js are each made up of multiple chunks above. Scroll up and confirm each chunk is present in order. Here is the full HomeView.vue for comparison:

<template>
  <div class="home">
    <h1>Workflow Dashboard</h1>
    <div class="workflow-list">
      <WorkflowCard
        v-for="workflow in workflows"
        :key="workflow.id"
        :workflow="workflow"
      />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { workflowData } from '../data/workflows.js'
import WorkflowCard from '../components/WorkflowCard.vue'

const workflows = ref(workflowData)
</script>
Clean up default boilerplate files

The scaffolding tool created default example components and CSS files that you no longer need. Removing them keeps your project tidy.

  • In the VS Code Explorer, expand src/components/.
  • Delete any default files you see there (such as HelloWorld.vue, TheWelcome.vue, WelcomeItem.vue, or an icons/ folder). Right-click each one and select Delete. Keep only your WorkflowCard.vue.
  • Expand src/assets/ and delete everything inside it (files like base.css, main.css, or logo.svg).
  • Open src/views/AboutView.vue if it exists and delete the file.
  • Save any open files and check your browser. The dashboard should still show 6 styled workflow cards with no errors.

Seeing a compile error after deleting files?

  • If your terminal shows an import error, one of the remaining files still imports a component you just deleted. Check App.vue and HomeView.vue for any leftover import lines that reference the deleted files and remove them.
  • help me fix a compile error after removing default scaffold files

Before you move on, take a guess: what do you think will happen if you click one of the workflow cards right now?

Try clicking one. The URL changes to something like /workflow/1, but you see a blank page. The <router-link> in your card points to a route that does not exist yet. You will fix that in Step 4.

Your dashboard is alive. You have reactive data feeding into a reusable component, and Vue re-renders automatically when that data changes. Next up, you will add search and status filtering so you can narrow down the list in real-time.

Add Search and Status Filtering

Your dashboard now shows six workflow cards with names, statuses, and durations.

A static list is hard to use when you need to find a specific workflow among dozens of executions. This is where Vue's reactivity shines. Computed properties automatically update the displayed list as the user types or clicks a filter, with zero manual DOM code.

In this step, get ready to:
  • Add a search input bound with v-model for real-time text filtering.
  • Create status filter buttons with dynamic active styling using v-bind:class.
  • Build a computed property that filters workflows by name and status simultaneously.
Add the search input and filter buttons

Vue's v-model directive creates a two-way binding between an input element and a reactive variable. When the user types, the variable updates instantly.

  • In VS Code, open src/views/HomeView.vue.
  • Find the <h1>Workflow Dashboard</h1> line in the template.
  • Add this controls section directly below the <h1> tag (above the <div class="workflow-list"> div):
<div class="controls">
  <input
    v-model="searchText"
    type="text"
    placeholder="Search workflows..."
    class="search-input"
  />
  <div class="filter-buttons">
    <button
      v-for="status in statuses"
      :key="status"
      :class="['filter-btn', { active: activeFilter === status }]"
      @click="activeFilter = status"
    >
      {{ status }}
    </button>
  </div>
</div>

What does this template do?

  • v-model="searchText" binds the input value to a reactive variable. Whatever the user types is instantly reflected in searchText.
  • v-for="status in statuses" loops through an array of status options to create one button per filter.
  • :class="['filter-btn', { active: activeFilter === status }]" dynamically adds the active class only to the button that matches the currently selected filter.
  • @click="activeFilter = status" updates the active filter when a button is clicked.
Create the computed filtering logic

A computed property in Vue automatically recalculates its value whenever any reactive dependency it reads changes. You define the filtering logic once, and Vue handles re-running it every time searchText or activeFilter changes.

  • In the same file, replace the entire <script setup> block with this updated version:
import { ref, computed } from 'vue'
import { workflowData } from '../data/workflows.js'
import WorkflowCard from '../components/WorkflowCard.vue'

const workflows = ref(workflowData)
const searchText = ref('')
const activeFilter = ref('all')
const statuses = ['all', 'success', 'failed', 'running']

const filteredWorkflows = computed(() => {
  return workflows.value.filter(workflow => {
    const matchesSearch = workflow.name
      .toLowerCase()
      .includes(searchText.value.toLowerCase())
    const matchesStatus =
      activeFilter.value === 'all' || workflow.status === activeFilter.value
    return matchesSearch && matchesStatus
  })
})

What does this script do?

  • searchText starts as an empty string. As the user types, v-model updates it automatically.
  • activeFilter tracks which status button is selected. It defaults to 'all' so every workflow shows initially.
  • filteredWorkflows is a computed property that filters the array by both name (case-insensitive substring match) and status. Vue re-runs this function every time either dependency changes.
  • Now update the template's workflow list to use the computed property instead of the raw array. Find this line in the template:
<WorkflowCard
  v-for="workflow in workflows"
  :key="workflow.id"
  :workflow="workflow"
/>
  • Replace workflows with filteredWorkflows in the v-for, and add a no-results message below the WorkflowCard component. The updated section looks like this:
<WorkflowCard
  v-for="workflow in filteredWorkflows"
  :key="workflow.id"
  :workflow="workflow"
/>
<p v-if="filteredWorkflows.length === 0" class="no-results">
  No workflows match your search.
</p>
  • Save HomeView.vue.
  • Refresh your browser at http://localhost:5173. You should see a text input and four buttons (all, success, failed, running) above the workflow cards.

Don't see the search input or filter buttons?

  • Check that the <div class="controls"> block sits between the <h1> and the <div class="workflow-list"> in your template.
  • Make sure your <script setup> imports computed from 'vue'. A missing import causes a runtime error.
  • If you see a blank page, open your browser's DevTools console (F12) and look for error messages pointing to undefined variables.

Help me debug my HomeView.vue search and filter controls.

Test the full interaction

Before you test, predict what will happen if you type etl in the search box while the failed filter is active. Will any cards appear?

  • Type backup in the search box. The list narrows to only the backup-database card.
  • Clear the search box and click the failed button. Only send-weekly-report and generate-invoices appear.
  • Now type etl with the failed filter still active. No cards appear because etl-daily-import has a success status.
  • Click all and clear the search. All 6 cards return.

Why did the list update without DOM code?

Vue's reactivity system tracks which reactive variables each computed property reads. When searchText or activeFilter changes, Vue knows filteredWorkflows depends on them and recalculates it automatically.

The v-for directive watches filteredWorkflows and re-renders only the cards that changed. You never wrote a single document.querySelector or innerHTML call.

✔️ Awesome, I've got everything!

Great. Double check you've saved HomeView.vue and confirmed both search and filter work together.

ⓧ I'd like to double check the full code

Here is the complete src/views/HomeView.vue at this point:

<template>
  <div class="home">
    <h1>Workflow Dashboard</h1>

    <div class="controls">
      <input
        v-model="searchText"
        type="text"
        placeholder="Search workflows..."
        class="search-input"
      />
      <div class="filter-buttons">
        <button
          v-for="status in statuses"
          :key="status"
          :class="['filter-btn', { active: activeFilter === status }]"
          @click="activeFilter = status"
        >
          {{ status }}
        </button>
      </div>
    </div>
  </div>
</template>
    <div class="workflow-list">
      <WorkflowCard
        v-for="workflow in filteredWorkflows"
        :key="workflow.id"
        :workflow="workflow"
      />
      <p v-if="filteredWorkflows.length === 0" class="no-results">
        No workflows match your search.
      </p>
    </div>
  </div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { workflowData } from '../data/workflows.js'
import WorkflowCard from '../components/WorkflowCard.vue'

const workflows = ref(workflowData)
const searchText = ref('')
const activeFilter = ref('all')
const statuses = ['all', 'success', 'failed', 'running']

const filteredWorkflows = computed(() => {
  return workflows.value.filter(workflow => {
    const matchesSearch = workflow.name
      .toLowerCase()
      .includes(searchText.value.toLowerCase())
    const matchesStatus =
      activeFilter.value === 'all' || workflow.status === activeFilter.value
    return matchesSearch && matchesStatus
  })
})
</script>

Your dashboard now lets users search and filter workflows in real time with zero manual DOM manipulation. Next up, you'll make those workflow cards clickable and navigate to a detail page using Vue Router.

Navigate to a Workflow Detail Page

Your dashboard now filters and searches workflows beautifully. But try clicking one of those workflow cards. Nothing happens.

You need navigation to a detail page where you can see a workflow's full execution information. Vue Router's dynamic routes solve this. This is the same pattern Kestra uses to navigate from an execution list to an execution detail view.

In this step, get ready to:
  • Create a WorkflowDetail view that reads the workflow ID from the URL.
  • Register a dynamic route so Vue Router knows how to reach the detail page.
  • Add back navigation and error handling for invalid workflow IDs.
Create the detail view component

Dynamic routes let you define a URL pattern like /workflow/:id where :id changes depending on which workflow the user clicked. The component at that route reads the ID from the URL and looks up the matching data.

  • Create a new file at src/views/WorkflowDetail.vue.
  • Add the template section that displays workflow details when a match is found:
<template>
  <div class="detail">
    <div v-if="workflow" class="detail-content">
      <div class="detail-header">
        <h1>{{ workflow.name }}</h1>
        <span :class="['status-badge', `status-${workflow.status}`]">
          {{ workflow.status }}
        </span>
      </div>

      <div class="detail-info">
        <div class="info-row">
          <span class="label">Namespace:</span>
          <span>{{ workflow.namespace }}</span>
        </div>
        <div class="info-row">
          <span class="label">Duration:</span>
          <span>{{ workflow.duration }}</span>
        </div>
        <div class="info-row">
          <span class="label">Started At:</span>
          <span>{{ workflow.startedAt }}</span>
        </div>
        <div class="info-row">
          <span class="label">ID:</span>
          <span>{{ workflow.id }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

What does this template do?

The v-if="workflow" directive only renders the content if a matching workflow was found. Inside, it displays the workflow's name with a colour-coded status badge, plus a table of detail rows.

  • Below the template, add the script setup that reads the route parameter and looks up the workflow:
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { workflowData } from '../data/workflows.js'

const route = useRoute()
const workflow = computed(() => workflowData.find(w => w.id === Number(route.params.id)))
</script>

What does this script do?

useRoute() gives you access to the current URL's parameters. route.params.id reads whatever value appears after /workflow/ in the URL.

The computed property automatically re-evaluates whenever the route changes. It converts the URL param to a number (since route params are strings) and finds the matching workflow in the data array.

  • Save src/views/WorkflowDetail.vue.
Register the dynamic route

The component exists, but Vue Router does not know when to display it. You need to register a route that tells the router: when the URL matches /workflow/:id, load WorkflowDetail.

  • Open src/router/index.js.
  • Find the closing of the home route object:
    {
      path: '/',
      name: 'home',
      component: HomeView
    }
  • Add a comma after the closing brace of that route, then add the new dynamic route below it:
    {
      path: '/workflow/:id',
      name: 'workflow-detail',
      component: () => import('../views/WorkflowDetail.vue')
    }

What does this route do?

The :id segment is a dynamic parameter. URLs like /workflow/1, /workflow/5, or any number will all match this route.

The () => import(...) syntax is lazy loading. The browser only downloads WorkflowDetail's code when someone actually navigates to it, keeping the initial page load fast.

  • Save src/router/index.js.

What about WorkflowCard?

If you open src/components/WorkflowCard.vue, you will see it already wraps the entire card in a <router-link> pointing to /workflow/${workflow.id}. That was set up in an earlier step. Now that the route exists, those links actually have somewhere to go.

Before you test, do you think clicking a workflow card will show its details, or will something go wrong?

  • Switch to your browser at http://localhost:5173.
  • Click any workflow card on the dashboard.

The URL changes to something like /workflow/1 and you see the workflow's name, status, namespace, duration, started time, and ID displayed on the page.

Seeing a blank page after clicking?

Make sure you added a comma after the first route object's closing brace in index.js. A missing comma between routes causes a syntax error.

Check that the import path in the new route matches exactly: ../views/WorkflowDetail.vue. A typo in the path silently fails.

Help me debug my dynamic route

Add back navigation and error handling

You can reach the detail page, but there is no way to get back to the dashboard without manually editing the URL. You also need to handle the case where someone types an invalid ID like /workflow/999.

  • In src/views/WorkflowDetail.vue, find this line near the top of your template:
  <div class="detail">
  • Add a back navigation link directly below it, with a blank line before the v-if block:
    <router-link to="/" class="back-link">Back to Dashboard</router-link>
  • Now find the closing </div> that closes the v-if detail-content block. Add this v-else block below it (before the final two closing </div> tags):
    <div v-else class="not-found">
      <h2>Workflow not found</h2>
      <p>The workflow you are looking for does not exist.</p>
    </div>

How does v-if and v-else work here?

Vue's v-if and v-else work as a pair. If the workflow computed property returns a match, the detail content renders. If it returns undefined (no match found), the "not found" message renders instead.

  • Save src/views/WorkflowDetail.vue.
  • In your browser, click any workflow card to navigate to its detail page.
  • Click the Back to Dashboard link at the top. You should return to the full workflow list.

Now test the error handling. What do you think will happen if you type a workflow ID that does not exist?

  • Manually change the URL in your browser's address bar to http://localhost:5173/workflow/999 and press Enter.

You see "Workflow not found" and a message that the workflow does not exist. The v-else block catches any invalid ID gracefully.

Back link not showing up?

Make sure the <router-link> is inside the <div class="detail"> wrapper but OUTSIDE the v-if block. If it is inside the v-if, it disappears when the workflow is not found.

Help me fix my back navigation link placement

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Your src/views/WorkflowDetail.vue should contain these parts in order:

  • A <template> block with a <div class="detail"> wrapper.
  • A <router-link to="/"> back link as the first child.
  • A v-if="workflow" block showing name, status badge, namespace, duration, started at, and ID.
  • A v-else block showing "Workflow not found".
  • A <script setup> importing computed, useRoute, and workflowData.

Your src/router/index.js should look like this:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/workflow/:id',
      name: 'workflow-detail',
      component: () => import('../views/WorkflowDetail.vue')
    }
  ]
})

export default router

You now have a working multi-page app with dynamic routing. Next up, you will move the workflow data into a Pinia store so both the list page and detail page share a single source of truth.

Manage State with a Pinia Store

Your dashboard can display workflows, filter them, and navigate to a detail page. But there is a hidden problem: both HomeView.vue and WorkflowDetail.vue import the workflow data separately, each with their own copy.

If one page adds a workflow, the other page has no idea. Pinia solves this by providing a single store that any component can read from. This is exactly how Kestra organizes its frontend state with stores like useFlowStore and useExecutionsStore.

In this step, get ready to:
  • Create a Pinia store that holds your workflow data in a single reactive source.
  • Refactor both views to read from the store instead of importing data separately.
  • Add an action that modifies shared state and see both views update automatically.
Create the Pinia store

A Pinia store is a dedicated module that holds data any component can access. You will define yours using the Composition API style, which uses ref and computed just like inside a component.

  • Create a new file at src/stores/workflows.js and paste the following code:
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { workflowData } from '../data/workflows.js'

export const useWorkflowsStore = defineStore('workflows', () => {
  // Single source of truth for all workflow data
  const workflows = ref([...workflowData])

  // Getter that finds a workflow by its ID
  const getWorkflowById = computed(() => {
    return (id) => workflows.value.find(w => w.id === Number(id))
  })

  return { workflows, getWorkflowById }
})

What does this code do?

defineStore('workflows', () => { ... }) creates a named store using the Composition API pattern. The function body works just like a component's <script setup>.

  • workflows is a ref holding a copy of the mock data. Any component that reads from this ref stays in sync.
  • getWorkflowById is a computed that returns a lookup function. It converts the route parameter to a number so it matches the workflow's id field.
  • Save src/stores/workflows.js.

Why the Composition API style?

Pinia supports two store styles: Options API and Composition API. Kestra's codebase uses Composition API stores exclusively. Learning this pattern now means you can read Kestra's store files without translation.

Update HomeView to read from the store

The store exists but nothing uses it yet. You will now rewire HomeView.vue to get its workflows from the store instead of importing the data file directly.

  • In src/views/HomeView.vue, find the <script setup> section.
  • Replace the entire script section with the following code:
<script setup>
import { ref, computed } from 'vue'
import { useWorkflowsStore } from '../stores/workflows.js'
import WorkflowCard from '../components/WorkflowCard.vue'

// Connect to the shared Pinia store
const workflowsStore = useWorkflowsStore()

const searchText = ref('')
const activeFilter = ref('all')
const statuses = ['all', 'success', 'failed', 'running']

// Filter from the store's workflows instead of a local copy
const filteredWorkflows = computed(() => {
  return workflowsStore.workflows.filter(workflow => {
    const matchesSearch = workflow.name
      .toLowerCase()
      .includes(searchText.value.toLowerCase())
    const matchesStatus =
      activeFilter.value === 'all' || workflow.status === activeFilter.value
    return matchesSearch && matchesStatus
  })
})
</script>

What changed?

  • The import of workflowData is gone. The component no longer needs its own copy of the raw data.
  • useWorkflowsStore() connects to the shared store. workflowsStore.workflows is the single reactive array both views will share.
  • The local const workflows = ref(workflowData) line is removed because the store owns that data now.
  • Save HomeView.vue and refresh your browser.
  • You should see the same dashboard with all 6 workflow cards, search, and filters working exactly as before.

Seeing a blank page or console error?

  • Check that the import path is exactly '../stores/workflows.js'. A typo in the path causes a module-not-found error.
  • Make sure you removed the old const workflows = ref(workflowData) line. Keeping it alongside the store creates a naming conflict.
  • Confirm src/stores/workflows.js exports useWorkflowsStore (not a default export).

Help me debug my Pinia store import in HomeView

Update WorkflowDetail to use the store

The detail page still imports workflowData directly and filters it locally. Switching it to the store means both pages read from the same source.

  • In src/views/WorkflowDetail.vue, find the <script setup> section.
  • Replace the entire script section with this code:
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useWorkflowsStore } from '../stores/workflows.js'

const route = useRoute()
const workflowsStore = useWorkflowsStore()

// Use the store's getter instead of filtering locally
const workflow = computed(() => workflowsStore.getWorkflowById(route.params.id))
</script>

What does this change?

Instead of importing the raw array and calling .find() itself, the component now calls workflowsStore.getWorkflowById(route.params.id). The lookup logic lives in the store, so every consumer uses the same code path.

  • Save WorkflowDetail.vue and click any workflow card on the dashboard.
  • The detail page still shows the workflow's full info (name, namespace, status, duration, started time, and ID).
  • Click the back link to return to the dashboard and confirm it still renders all 6 cards.

Detail page showing 'Workflow not found'?

  • Open src/stores/workflows.js and confirm getWorkflowById is included in the return statement.
  • Make sure the getter converts the ID to a number with Number(id). Route params are strings, so a strict equality check without conversion always fails.

Help me fix my getWorkflowById getter

Add an action and prove shared reactivity works

Reading from the store is only half the story. The real power is that any component can also write to the store, and every other component automatically sees the change.

  • In src/stores/workflows.js, find the line that says return { workflows, getWorkflowById }.
  • Add the following function above the return statement, then update the return to include it:
  // Action: add a new workflow to the beginning of the list
  function addWorkflow(workflow) {
    workflows.value.unshift(workflow)
  }

  return { workflows, getWorkflowById, addWorkflow }

What does addWorkflow do?

unshift() inserts the new workflow at the beginning of the array, so it appears at the top of the dashboard. Because workflows is a reactive ref, Vue automatically re-renders any component that reads from it.

  • Save src/stores/workflows.js.

Now test it. You will temporarily call this action when the home page mounts, to prove that data added from one place shows up everywhere.

  • In src/views/HomeView.vue, find the import line that starts with import { ref, computed } from 'vue'.
  • Add onMounted to the import so it becomes:
import { ref, computed, onMounted } from 'vue'
  • At the bottom of the <script setup> section, after the filteredWorkflows computed, add this temporary test:
// TEMPORARY TEST - remove after verifying
onMounted(() => {
  workflowsStore.addWorkflow({
    id: 99,
    name: 'test-pinia-store',
    namespace: 'testing.demo',
    status: 'running',
    duration: '0m 00s',
    startedAt: '2026-07-14 12:00:00'
  })
})

Before you save and check the browser, what do you think will happen? Will you see 6 workflow cards or 7?

  • Save HomeView.vue and check the dashboard in your browser.

You should see 7 workflow cards. The new test-pinia-store card appears at the top of the list with a blue "running" badge. You did not change the template. The store added the data, and Vue's reactivity rendered it automatically.

  • Click the test-pinia-store card to navigate to its detail page.
  • It shows the full details (namespace, duration, started time). Both views read from the same store.

Still seeing only 6 cards?

  • Make sure you added addWorkflow to the return statement in your store. If it is not exported, the component cannot call it.
  • Check that you imported onMounted from 'vue'. A missing import causes a reference error in the console.

Help me debug why addWorkflow is not adding a card to the dashboard

The test proves shared reactivity works. Now remove the temporary code so the dashboard goes back to its normal 6 workflows.

  • In HomeView.vue, delete the entire onMounted(() => { ... }) block you just added.
  • Change the Vue import back to:
import { ref, computed } from 'vue'
  • Save HomeView.vue and confirm the dashboard is back to 6 workflow cards.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the final src/stores/workflows.js:

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { workflowData } from '../data/workflows.js'

export const useWorkflowsStore = defineStore('workflows', () => {
  const workflows = ref([...workflowData])

  const getWorkflowById = computed(() => {
    return (id) => workflows.value.find(w => w.id === Number(id))
  })

  function addWorkflow(workflow) {
    workflows.value.unshift(workflow)
  }

  return { workflows, getWorkflowById, addWorkflow }
})

Here is the <script setup> section of src/views/HomeView.vue (template and style are unchanged):

<script setup>
import { ref, computed } from 'vue'
import { useWorkflowsStore } from '../stores/workflows.js'
import WorkflowCard from '../components/WorkflowCard.vue'

const workflowsStore = useWorkflowsStore()

const searchText = ref('')
const activeFilter = ref('all')
const statuses = ['all', 'success', 'failed', 'running']

const filteredWorkflows = computed(() => {
  return workflowsStore.workflows.filter(workflow => {
    const matchesSearch = workflow.name
      .toLowerCase()
      .includes(searchText.value.toLowerCase())
    const matchesStatus =
      activeFilter.value === 'all' || workflow.status === activeFilter.value
    return matchesSearch && matchesStatus
  })
})
</script>

Here is the <script setup> section of src/views/WorkflowDetail.vue (template and style are unchanged):

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useWorkflowsStore } from '../stores/workflows.js'

const route = useRoute()
const workflowsStore = useWorkflowsStore()

const workflow = computed(() => workflowsStore.getWorkflowById(route.params.id))
</script>

Your entire app now reads from a single source of truth. Any component can call addWorkflow and the list updates everywhere. Next up, you will use this action to build a trigger feature that lets users create new workflow executions from the UI.

Secret mission

Add a Trigger Workflow Feature

In Kestra, users can trigger workflow executions directly from the UI. Can you build a form that creates new executions and watches them appear on your dashboard in real-time?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your machine, so there are no ongoing costs.

Resources you used:

  • The workflow-dashboard project folder on your Desktop.
  • The Vite dev server process running in your terminal.
  • Node.js packages installed inside node_modules.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your Vue 3 dashboard.

  • Your dev server stays active at http://localhost:5173.
  • Your project files remain on your Desktop, ready for editing.

Pause - I'll come back to this later

Shut down the running dev server to free up your terminal. Your project files stay on disk so you can resume anytime.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the server.
  • When you want to resume, open a terminal in the workflow-dashboard folder and run:
npm run dev

Delete - I don't want to use this again

Remove all project files from your machine entirely. This cannot be undone.

  • Switch to the terminal where the dev server is running and press Ctrl+C to stop it.
  • Remove the project folder by running the command for your operating system:

macOS

rm -rf ~/Desktop/workflow-dashboard

Windows

rmdir /s /q %USERPROFILE%\Desktop\workflow-dashboard
  • Confirm the folder is gone by running ls ~/Desktop (macOS) or dir %USERPROFILE%\Desktop (Windows). The workflow-dashboard folder should no longer appear.

Nice Work!

Nice Work!

You just built a fully functional multi-page Vue 3 application from scratch. Your Workflow Status Dashboard displays live data, filters results in real-time, and navigates between pages using the same frontend patterns that power the Kestra open-source project.

You've learned how to:

  • Build a reactive dashboard with Vue 3's Composition API (ref and computed) that automatically updates the UI when data changes.
  • Implement client-side routing with Vue Router and dynamic route parameters so users can navigate between a list view and individual detail pages.
  • Set up a Pinia store for shared state management, mirroring how Kestra organizes its frontend data with stores like useFlowStore and useExecutionsStore.
  • Secret Mission: Built a form-driven trigger feature that creates new workflow executions and reactively updates the entire dashboard in real-time.

Ready to quiz yourself?