příklady / plánované úlohy

Hlídač dostupnosti

Pinguje URL podle plánu a vede historii dostupnosti, kterou si projdete.

cronlibsqlsecrets

Tato služba dělá v jednom souboru dvě věci. Každých pět minut naplánovaný běh stáhne vaši TARGET_URL, zaznamená, jestli odpověděla a jak dlouho to trvalo, a výsledek uloží do připojené libsql databáze. Když službu otevřete v prohlížeči, stejný handler místo toho vykreslí poslední historii jako stránku se stavem.

Plán je přímo v kódu: export const crons registruje úlohu probe a každé spuštění volá handler s hlavičkami X-Fb-Trigger a X-Fb-Cron, takže naplánovaný běh pozná od zobrazení stránky. Export run() napojuje tlačítko Run, takže kontrolu zaznamenáte hned po nasazení a nemusíte čekat na další pětiminutový tik. Nasazením vznikne projekt, databáze checks (připojená do služby jako UPTIME_DB) a formulář se zeptá na URL, kterou má hlídat.

kód
main.ts
import { createClient } from "npm:@libsql/client@0.14.0/web";

// An uptime monitor in a single service. It does two jobs:
//   - on a schedule it pings a URL and records the result (up/down + latency),
//   - on a web request it renders the recent history as a status page.
//
// The schedule is defined right here in the code: the crons export below registers
// a cron job named "probe". Each firing calls the default handler with the headers
// "X-Fb-Trigger: cron" and "X-Fb-Cron: probe", so we branch on that to decide
// which job to do. The run() export powers the Run button for an instant check.

export const crons = { "probe": "*/5 * * * *" };

// Read a secret that is only present in some setups. In a normal deploy the linked
// database and the TARGET_URL secret are always configured; this just keeps the service
// from crashing if you copy it somewhere they are not set up yet.
function optionalSecret(name: string): string | undefined {
  try {
    return Deno.env.get(name) || undefined;
  } catch {
    return undefined; // this service was not granted access to that variable
  }
}

// This service was deployed with a libsql database linked as UPTIME_DB. We read that;
// if you copy the service without the link it falls back to its built-in database.
const db = createClient({
  url: optionalSecret("UPTIME_DB") ?? Deno.env.get("DATABASE_URL")!,
});

// The URL to watch is provided as the TARGET_URL secret when you deploy.
const TARGET_URL = optionalSecret("TARGET_URL") ?? "https://example.com";

// One table holds every probe result. datetime('now') is UTC in SQLite.
async function ensureSchema(): Promise<void> {
  await db.execute(
    `CREATE TABLE IF NOT EXISTS checks (
       id         INTEGER PRIMARY KEY AUTOINCREMENT,
       url        TEXT    NOT NULL,
       ok         INTEGER NOT NULL,
       status     INTEGER,
       latency_ms INTEGER NOT NULL,
       error      TEXT,
       at         TEXT    NOT NULL DEFAULT (datetime('now'))
     )`,
  );
}

// Probe TARGET_URL once and store how it went.
async function runCheck(): Promise<void> {
  await ensureSchema();
  const started = Date.now();
  try {
    const res = await fetch(TARGET_URL);
    await res.body?.cancel(); // we only need the status, not the body
    await db.execute({
      sql: "INSERT INTO checks (url, ok, status, latency_ms) VALUES (?, ?, ?, ?)",
      args: [TARGET_URL, res.ok ? 1 : 0, res.status, Date.now() - started],
    });
  } catch (err) {
    // A thrown fetch means a DNS/TLS/timeout failure: a real outage worth recording.
    await db.execute({
      sql: "INSERT INTO checks (url, ok, status, latency_ms, error) VALUES (?, ?, ?, ?, ?)",
      args: [TARGET_URL, 0, null, Date.now() - started, String(err)],
    });
  }
}

function escapeHtml(s: string): string {
  return s
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

// Render the recent probe history as a small status page.
async function renderPage(): Promise<Response> {
  await ensureSchema();
  const summary = await db.execute(
    "SELECT count(*) AS total, coalesce(sum(ok), 0) AS up FROM checks",
  );
  const total = Number(summary.rows[0].total);
  const up = Number(summary.rows[0].up);
  const uptime = total > 0 ? ((up / total) * 100).toFixed(1) : "0.0";

  const recent = await db.execute(
    "SELECT ok, status, latency_ms, error, at FROM checks ORDER BY id DESC LIMIT 50",
  );
  const rows = recent.rows
    .map((r) => {
      const ok = Number(r.ok) === 1;
      const detail = ok
        ? String(r.status)
        : escapeHtml(String(r.error ?? r.status ?? "error"));
      return `      <tr>
        <td>${escapeHtml(String(r.at))} UTC</td>
        <td class="${ok ? "up" : "down"}">${ok ? "up" : "down"}</td>
        <td>${detail}</td>
        <td>${escapeHtml(String(r.latency_ms))} ms</td>
      </tr>`;
    })
    .join("\n");

  const table =
    total === 0
      ? `<p class="muted">No checks yet. The first one runs on the next tick.</p>`
      : `<table>
      <thead><tr><th>Time</th><th>Result</th><th>Detail</th><th>Latency</th></tr></thead>
      <tbody>
${rows}
      </tbody>
    </table>`;

  const html = `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Uptime monitor</title>
    <style>
      body { font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; max-width: 720px; margin: 2.5rem auto; padding: 0 1rem; }
      h1 { font-size: 1.2rem; }
      table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
      th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #e2e2e2; }
      th { color: #666; font-weight: 600; }
      .up { color: #0a7d2c; }
      .down { color: #c0341d; }
      .muted { color: #888; }
    </style>
  </head>
  <body>
    <h1>Uptime monitor</h1>
    <p class="muted">Watching <strong>${escapeHtml(TARGET_URL)}</strong> every 5 minutes. Uptime ${uptime}% over ${total} checks.</p>
    ${table}
  </body>
</html>
`;

  return new Response(html, {
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}

// The Run button: probe the target right now instead of waiting for the next tick.
export async function run(): Promise<string> {
  await runCheck();
  return "check recorded";
}

export default async (req: Request): Promise<Response> => {
  // The scheduled probe and the status page share this one handler.
  if (req.headers.get("x-fb-trigger") === "cron") {
    await runCheck();
    return new Response("ok\n"); // the scheduled run has no visible page
  }
  return renderPage();
};

Go beyond what seems possible.