HomeBlogBuilding your first AirLink addon

Building your first AirLink addon

This tutorial walks through building a simple addon that adds a server notes feature. Users can write notes on their servers, and admins can see all notes across all servers.

What we are building

An addon with:

  • A database table for notes
  • A route that shows notes on the server page
  • An admin page that lists all notes
  • A sidebar entry for the admin view

Step 1: the directory

mkdir -p panel/storage/addons/server-notes/views
cd panel/storage/addons/server-notes

Step 2: package.json

{
  "name": "Server Notes",
  "version": "1.0.0",
  "description": "Add notes to servers",
  "author": "you",
  "main": "index.ts",
  "router": "/server-notes",
  "migrations": [
    {
      "name": "server_notes_v1_create_table",
      "sql": "CREATE TABLE IF NOT EXISTS ServerNotes (id INTEGER PRIMARY KEY AUTOINCREMENT, serverUuid TEXT NOT NULL, userId INTEGER NOT NULL, note TEXT NOT NULL, createdAt TEXT NOT NULL DEFAULT (datetime('now')))"
    }
  ]
}

The migration creates a table with a server UUID, user ID, note text, and timestamp.

Step 3: the entry point

import { Router } from "express";
import path from "path";

export default function (router: Router, api: any) {
  const { logger, prisma } = api;

  // Admin page — list all notes
  router.get("/admin", async (req: any, res: any) => {
    if (
      !req.session?.user ||
      !(await api.utils.isUserAdmin(req.session.user.id))
    ) {
      return res.redirect("/login");
    }

    try {
      const notes = await prisma.$queryRaw`
        SELECT sn.*, u.username, s.name as serverName
        FROM ServerNotes sn
        LEFT JOIN Users u ON sn.userId = u.id
        LEFT JOIN Servers s ON sn.serverUuid = s.uuid
        ORDER BY sn.createdAt DESC
      `;

      res.render(path.join(api.viewsPath, "admin.ejs"), {
        user: req.session.user,
        req,
        notes,
        components: {
          header: api.getComponentPath("views/components/header"),
          template: api.getComponentPath("views/components/template"),
          footer: api.getComponentPath("views/components/footer"),
        },
      });
    } catch (error) {
      logger.error("server-notes admin error", error);
      res.status(500).send("something broke");
    }
  });

  // API — add a note
  router.post("/api/add", async (req: any, res: any) => {
    if (!req.session?.user)
      return res.status(401).json({ error: "not logged in" });

    const { serverUuid, note } = req.body;
    if (!serverUuid || !note)
      return res.status(400).json({ error: "missing fields" });

    try {
      await prisma.$executeRaw`
        INSERT INTO ServerNotes (serverUuid, userId, note)
        VALUES (${serverUuid}, ${req.session.user.id}, ${note})
      `;
      res.json({ success: true });
    } catch (error) {
      logger.error("server-notes add error", error);
      res.status(500).json({ error: "failed to add note" });
    }
  });

  // Add sidebar item
  api.ui.addSidebarItem({
    id: "server-notes",
    name: "All Notes",
    icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
    link: "/server-notes/admin",
    section: "admin",
    order: 60,
  });
}

Step 4: the admin view

Create views/admin.ejs:

<%- include(components.header, { title: 'Server Notes', user: user }) %>

<main class="h-screen m-auto">
  <div class="flex h-screen">
    <div class="w-60 h-full"><%- include(components.template) %></div>
    <div class="flex-1 p-6 overflow-y-auto pt-16">
      <div class="px-8 mt-5">
        <h1 class="text-base font-medium text-white">All Server Notes</h1>
        <p class="mt-1 text-sm text-neutral-500">
          Notes added by users on their servers
        </p>
      </div>
      <div class="px-8 mt-5">
        <% if (notes.length === 0) { %>
        <p class="text-neutral-500">No notes yet</p>
        <% } else { %>
        <div class="space-y-3">
          <% notes.forEach(function(note) { %>
          <div class="rounded-xl bg-neutral-900 p-4">
            <div class="flex items-center justify-between">
              <span class="text-sm font-medium text-white"
                ><%= note.serverName || 'Unknown' %></span
              >
              <span class="text-xs text-neutral-500"
                ><%= note.createdAt %></span
              >
            </div>
            <p class="mt-2 text-sm text-neutral-400"><%= note.note %></p>
            <p class="mt-1 text-xs text-neutral-600">
              by <%= note.username || 'unknown' %>
            </p>
          </div>
          <% }); %>
        </div>
        <% } %>
      </div>
    </div>
  </div>
</main>

<%- include(components.footer) %>

Step 5: enable and test

  1. Restart the panel
  2. Go to Admin > Addons and enable “Server Notes”
  3. Visit /server-notes/admin to see the admin view
  4. Use the API endpoint to add notes programmatically

What you learned

  • How to define a migration in package.json
  • How to create routes with Express
  • How to use $queryRaw and $executeRaw for addon tables
  • How to register a sidebar item
  • How to render views with the panel’s layout components

From here you can add server-specific note views, subuser permissions, or integrate with the console to add notes from the terminal.