Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 185 additions & 112 deletions app/(dashboard)/alerts/page.tsx

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion app/(dashboard)/endpoints/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTimeRange } from "@/hooks/use-time-range";
import { useEndpoints } from "@/hooks/use-data";
import { useProjects } from "@/components/providers/project-provider";
import type { Endpoint } from "@/lib/types";

const METHODS = ["ALL", "GET", "POST", "PUT", "DELETE", "PATCH"] as const;
Expand All @@ -22,6 +23,7 @@ const STATUS_RANGES = [
] as const;

export default function EndpointsPage() {
const { activeProject } = useProjects();
const { range, setRange } = useTimeRange("24h");
const [selected, setSelected] = useState<Endpoint | null>(null);
const [search, setSearch] = useState("");
Expand Down Expand Up @@ -97,7 +99,7 @@ export default function EndpointsPage() {
return (
<div className="flex flex-col min-h-screen">
<Topbar
breadcrumb={[{ label: "Projects" }, { label: "My API" }, { label: "Endpoints" }]}
breadcrumb={[{ label: "Projects" }, { label: activeProject?.name ?? "—" }, { label: "Endpoints" }]}
actions={<TimeRangePicker value={range} onChange={setRange} />}
/>

Expand Down
4 changes: 3 additions & 1 deletion app/(dashboard)/traces/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ServiceMap } from "@/components/traces/service-map";
import { Badge } from "@/components/ui/badge";
import { cn, formatMs, timeAgo } from "@/lib/utils";
import { useTraces } from "@/hooks/use-data";
import { useProjects } from "@/components/providers/project-provider";
import type { Trace, TraceSpan } from "@/lib/types";

type DurationFilter = "any" | "<100" | "100-500" | "500-1000" | ">1000";
Expand All @@ -34,6 +35,7 @@ function matchesDuration(ms: number, filter: DurationFilter): boolean {
type ViewMode = "flamegraph" | "servicemap";

export default function TracesPage() {
const { activeProject } = useProjects();
const { data: traces } = useTraces();
const [selectedTrace, setSelectedTrace] = useState<Trace | null>(null);

Expand Down Expand Up @@ -62,7 +64,7 @@ export default function TracesPage() {

return (
<div className="flex flex-col min-h-screen">
<Topbar breadcrumb={[{ label: "Projects" }, { label: "My API" }, { label: "Traces" }]} />
<Topbar breadcrumb={[{ label: "Projects" }, { label: activeProject?.name ?? "—" }, { label: "Traces" }]} />

<div className="flex flex-1 gap-0">
{/* Trace list */}
Expand Down
112 changes: 112 additions & 0 deletions apps/api/api/routes/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@
from api.schemas import (
AlertHistoryOut,
AlertRuleOut,
ChannelIn,
ChannelOut,
ChannelTestResponse,
ChannelUpdate,
CreateAlertRuleRequest,
UpdateAlertRuleRequest,
)
from core.notify import deliver

router = APIRouter(prefix="/v1", tags=["alerts"])
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -192,3 +197,110 @@ async def get_alert_history(
)
for row in rows
]


# ─── Alert channels (real delivery targets) ──────────────────────────────────

def _channel_row_to_out(row: asyncpg.Record) -> ChannelOut:
return ChannelOut(
id=str(row["id"]),
type=row["type"],
name=row["name"],
webhook_url=row["webhook_url"],
enabled=row["enabled"],
last_delivery_at=row["last_delivery_at"].isoformat() if row["last_delivery_at"] else None,
last_delivery_ok=row["last_delivery_ok"],
created_at=row["created_at"].isoformat() if row["created_at"] else "",
)


@router.get("/channels", response_model=list[ChannelOut])
async def list_channels(
project_id: str = Depends(resolve_project_id),
conn: asyncpg.Connection = Depends(scoped_conn),
) -> list[ChannelOut]:
rows = await conn.fetch(
"SELECT * FROM alert_channels WHERE project_id = $1 ORDER BY created_at",
project_id,
)
return [_channel_row_to_out(r) for r in rows]


@router.post("/channels", response_model=ChannelOut, status_code=201)
async def create_channel(
body: ChannelIn,
project_id: str = Depends(resolve_project_id),
conn: asyncpg.Connection = Depends(get_db),
) -> ChannelOut:
row = await conn.fetchrow(
"""
INSERT INTO alert_channels (project_id, type, name, webhook_url, enabled)
VALUES ($1::uuid, $2, $3, $4, $5)
RETURNING *
""",
project_id, body.type, body.name, body.webhook_url, body.enabled,
)
return _channel_row_to_out(row)


@router.patch("/channels/{channel_id}", response_model=ChannelOut)
async def update_channel(
channel_id: str,
body: ChannelUpdate,
project_id: str = Depends(resolve_project_id),
conn: asyncpg.Connection = Depends(get_db),
) -> ChannelOut:
row = await conn.fetchrow(
"""
UPDATE alert_channels
SET name = COALESCE($3, name),
webhook_url = COALESCE($4, webhook_url),
enabled = COALESCE($5, enabled)
WHERE id = $1::uuid AND project_id = $2::uuid
RETURNING *
""",
channel_id, project_id, body.name, body.webhook_url, body.enabled,
)
if row is None:
raise HTTPException(status_code=404, detail="Channel not found")
return _channel_row_to_out(row)


@router.delete("/channels/{channel_id}", status_code=204, response_model=None, response_class=Response)
async def delete_channel(
channel_id: str,
project_id: str = Depends(resolve_project_id),
conn: asyncpg.Connection = Depends(get_db),
) -> Response:
result = await conn.execute(
"DELETE FROM alert_channels WHERE id = $1::uuid AND project_id = $2::uuid",
channel_id, project_id,
)
if result == "DELETE 0":
raise HTTPException(status_code=404, detail="Channel not found")
return Response(status_code=204)


@router.post("/channels/{channel_id}/test", response_model=ChannelTestResponse)
async def test_channel(
channel_id: str,
project_id: str = Depends(resolve_project_id),
conn: asyncpg.Connection = Depends(get_db),
) -> ChannelTestResponse:
row = await conn.fetchrow(
"SELECT type, webhook_url FROM alert_channels WHERE id = $1::uuid AND project_id = $2::uuid",
channel_id, project_id,
)
if row is None:
raise HTTPException(status_code=404, detail="Channel not found")
ok, detail = await deliver(
row["type"], row["webhook_url"],
title="Liveboard test alert",
summary="This is a test message from Liveboard. If you can see this, delivery works.",
severity="info",
)
await conn.execute(
"UPDATE alert_channels SET last_delivery_at = now(), last_delivery_ok = $2 WHERE id = $1::uuid",
channel_id, ok,
)
return ChannelTestResponse(ok=ok, detail=detail)
31 changes: 31 additions & 0 deletions apps/api/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,34 @@ class AlertHistoryOut(BaseModel):
channel: str
resolved: bool
duration: str


# ─── Alert channels (Phase: real alerting) ────────────────────────────────────

class ChannelIn(BaseModel):
type: str = Field(..., pattern="^(slack|discord|pagerduty|webhook)$")
name: str = Field(..., min_length=1, max_length=100)
webhook_url: Optional[str] = Field(default=None, max_length=1000)
enabled: bool = True


class ChannelUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=100)
webhook_url: Optional[str] = Field(default=None, max_length=1000)
enabled: Optional[bool] = None


class ChannelOut(BaseModel):
id: str
type: str
name: str
webhook_url: Optional[str]
enabled: bool
last_delivery_at: Optional[str]
last_delivery_ok: Optional[bool]
created_at: str


class ChannelTestResponse(BaseModel):
ok: bool
detail: str
2 changes: 2 additions & 0 deletions apps/api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class Settings(BaseSettings):

# ── AI (Phase 5 — Cerebras) ───────────────────────────────────────────────
cerebras_api_key: SecretStr = SecretStr("")
# Model id must be one your key can access (see `client.models.list()`).
cerebras_model: str = "gpt-oss-120b"

# ── CORS / realtime origins (Phase 8.5) ──────────────────────────────────
# Comma-separated list of allowed browser origins (dashboard + status page).
Expand Down
62 changes: 62 additions & 0 deletions apps/api/core/notify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Alert notification delivery — real HTTP POST to the configured channel.

Slack / Discord / generic webhooks are just JSON POSTs to an incoming-webhook
URL. PagerDuty uses its Events API v2 (webhook_url holds the routing key).
Returns (ok, detail) and never raises — a delivery failure must not crash the
evaluation loop.
"""
from __future__ import annotations

import logging

import httpx

logger = logging.getLogger(__name__)

_TIMEOUT = 8.0


async def deliver(
channel_type: str,
webhook_url: str | None,
*,
title: str,
summary: str,
severity: str,
) -> tuple[bool, str]:
if not webhook_url:
return False, "no webhook url configured"

try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
if channel_type == "slack":
r = await client.post(webhook_url, json={"text": f":rotating_light: *{title}*\n{summary}"})
elif channel_type == "discord":
r = await client.post(webhook_url, json={"content": f"🚨 **{title}**\n{summary}"})
elif channel_type == "pagerduty":
r = await client.post(
"https://events.pagerduty.com/v2/enqueue",
json={
"routing_key": webhook_url,
"event_action": "trigger",
"payload": {
"summary": f"{title} — {summary}"[:1024],
"severity": "critical" if severity == "critical" else "warning",
"source": "liveboard",
},
},
)
else: # generic webhook
r = await client.post(
webhook_url,
json={"title": title, "summary": summary, "severity": severity, "source": "liveboard"},
)

ok = 200 <= r.status_code < 300
if not ok:
logger.warning("Delivery to %s failed: HTTP %s", channel_type, r.status_code)
return ok, f"HTTP {r.status_code}"
except Exception as exc: # network / timeout / bad url
logger.warning("Delivery to %s errored: %s", channel_type, exc)
return False, str(exc)[:160]
50 changes: 50 additions & 0 deletions apps/api/migrations/versions/006_alert_channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Alert delivery channels (real Slack/Discord/PagerDuty/webhook targets)

Revision ID: 006
Revises: 005
Create Date: 2026-07-19

Backs the alerts "Channels" tab with real, per-project destinations. Alert
rules reference a channel by name (alert_rules.channel); the evaluation worker
looks the channel up here to get its webhook URL and delivers the notification.
"""

from alembic import op

revision = "006"
down_revision = "005"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS alert_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('slack', 'discord', 'pagerduty', 'webhook')),
name TEXT NOT NULL,
webhook_url TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_delivery_at TIMESTAMPTZ,
last_delivery_ok BOOLEAN,
created_at TIMESTAMPTZ DEFAULT now()
);
""")
op.execute("CREATE INDEX IF NOT EXISTS idx_alert_channels_project ON alert_channels(project_id);")

# RLS backstop — dashboard reads (dashboard_reader role) are scoped to the
# active project via app.project_id, same as the other tenant tables.
op.execute("GRANT SELECT ON alert_channels TO dashboard_reader;")
op.execute("ALTER TABLE alert_channels ENABLE ROW LEVEL SECURITY;")
op.execute("""
CREATE POLICY alert_channels_tenant_isolation ON alert_channels
FOR SELECT
TO dashboard_reader
USING (project_id = NULLIF(current_setting('app.project_id', true), '')::uuid);
""")


def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS alert_channels_tenant_isolation ON alert_channels;")
op.execute("DROP TABLE IF EXISTS alert_channels;")
Loading
Loading