SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
8.2 KB · 185 lines tsx
Raw Blame History
1"use client";23import { Bookmark as BookmarkIcon, Plus, Trash2 } from "lucide-react";4import Link from "next/link";5import { useCallback, useEffect, useState } from "react";6import { EventRow } from "@/components/event-row";7import { Chip, Empty, Panel, SkeletonRows } from "@/components/ui";8import type { Bookmark } from "@/lib/api";9import { relTime, SAVED_VIEWS, utcDateTime } from "@/lib/format";10import { ownerFetch } from "@/lib/owner";1112interface SavedView {13  id: string;14  name: string;15  query: string;16  created_at?: string;17}1819function normalizeQuery(q: string): string {20  const s = q.trim().replace(/^\/?live/, "").replace(/^\?/, "");21  return s;22}2324export function Bookmarks() {25  const [items, setItems] = useState<Bookmark[] | null>(null);26  const [views, setViews] = useState<SavedView[] | null>(null);27  const [err, setErr] = useState<string | null>(null);28  const [viewErr, setViewErr] = useState<string | null>(null);29  const [form, setForm] = useState({ name: "", query: "" });3031  const load = useCallback(32    () =>33      ownerFetch<{ items: Bookmark[] }>("/api/v1/bookmarks?limit=200")34        .then((r) => setItems(r.items))35        .catch((e: Error) => {36          setErr(e.message);37          setItems([]);38        }),39    [],40  );41  const loadViews = useCallback(42    () =>43      ownerFetch<{ items: SavedView[] }>("/api/v1/views")44        .then((r) => setViews(r.items))45        .catch((e: Error) => {46          setViewErr(e.message);47          setViews([]);48        }),49    [],50  );51  useEffect(() => {52    void load();53    void loadViews();54  }, [load, loadViews]);5556  const remove = async (id: string): Promise<void> => {57    setItems((prev) => prev?.filter((b) => b.id !== id) ?? prev);58    try {59      await ownerFetch(`/api/v1/bookmarks/${encodeURIComponent(id)}`, { method: "DELETE" });60    } catch (e) {61      setErr((e as Error).message);62      await load();63    }64  };6566  const createView = async (): Promise<void> => {67    const query = normalizeQuery(form.query);68    if (!query) {69      setViewErr("Paste a live-feed query string, e.g. group=security&signal_min=70");70      return;71    }72    setViewErr(null);73    try {74      await ownerFetch("/api/v1/views", { method: "POST", body: JSON.stringify({ name: form.name.trim() || "My view", query }) });75      setForm({ name: "", query: "" });76      await loadViews();77    } catch (e) {78      setViewErr((e as Error).message);79    }80  };81  const removeView = async (id: string): Promise<void> => {82    setViews((prev) => prev?.filter((v) => v.id !== id) ?? prev);83    try {84      await ownerFetch(`/api/v1/views/${encodeURIComponent(id)}`, { method: "DELETE" });85    } catch (e) {86      setViewErr((e as Error).message);87      await loadViews();88    }89  };9091  return (92    <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]">93      <Panel title={<span>Saved events <span className="font-mono text-fg-subtle">{items?.length ?? "…"}</span></span>} dense>94        {items === null ? (95          <SkeletonRows rows={6} />96        ) : items.length ? (97          <ul className="divide-y divide-line">98            {items.map((b) => (99              <li key={b.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2 pr-2">100                <div className="min-w-0 [&>article]:border-b-0">101                  <EventRow ev={b} showDate />102                </div>103                <div className="flex flex-col items-end gap-1 pt-2">104                  <button type="button" aria-label="Remove bookmark" title="Remove bookmark" onClick={() => remove(b.id)} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-subtle hover:border-line-strong hover:text-danger">105                    <Trash2 className="size-3.5" />106                  </button>107                  <span className="inline-flex items-center gap-1 whitespace-nowrap font-mono text-[10.5px] text-fg-subtle" title={`Saved ${utcDateTime(b.bookmarked_at)}${b.note ? ` · ${b.note}` : ""}`}>108                    <BookmarkIcon className="size-3 text-signal" /> {relTime(b.bookmarked_at)}109                  </span>110                </div>111              </li>112            ))}113          </ul>114        ) : (115          <Empty icon={<BookmarkIcon className="size-5 text-fg-subtle" />}>116            No saved events yet — use the bookmark icon in the intelligence panel.117            <div className="mt-2">118              <Link href="/live" className="text-info hover:underline">Open the live feed →</Link>119            </div>120          </Empty>121        )}122        {err && <p className="border-t border-line px-3 py-2 text-[11px] text-danger">{err}</p>}123      </Panel>124125      <aside className="flex min-w-0 flex-col gap-4">126        <Panel title={<span>Saved views <span className="font-mono text-fg-subtle">{views?.length ?? "…"}</span></span>} dense>127          {views === null ? (128            <SkeletonRows rows={3} />129          ) : views.length ? (130            <ul className="divide-y divide-line">131              {views.map((v) => (132                <li key={v.id} className="flex items-start gap-2 px-3 py-2 text-[13px]">133                  <div className="min-w-0 flex-1">134                    <Link href={`/live?${v.query}`} className="block truncate font-medium hover:underline">{v.name}</Link>135                    <div className="mt-0.5 truncate font-mono text-[11px] text-fg-subtle" title={v.query}>?{v.query}</div>136                  </div>137                  <button type="button" aria-label={`Delete view ${v.name}`} onClick={() => removeView(v.id)} className="mt-0.5 text-fg-subtle hover:text-danger">138                    <Trash2 className="size-3.5" />139                  </button>140                </li>141              ))}142            </ul>143          ) : (144            <Empty>No saved views yet. Filter the live feed, then paste its query string below.</Empty>145          )}146          <form147            className="flex flex-col gap-1.5 border-t border-line p-2 text-[12.5px]"148            onSubmit={(e) => {149              e.preventDefault();150              void createView();151            }}152          >153            <label className="sr-only" htmlFor="view-name">View name</label>154            <input id="view-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="View name" maxLength={60} className="h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]" />155            <label className="sr-only" htmlFor="view-query">Query string</label>156            <input id="view-query" value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} placeholder="group=security&signal_min=70" maxLength={600} className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" />157            <div className="flex items-center justify-between gap-2">158              <button type="button" onClick={() => setForm({ ...form, query: typeof window !== "undefined" && window.location.pathname.startsWith("/live") ? normalizeQuery(window.location.search) : form.query })} className="text-[11px] text-fg-subtle hover:text-fg" title="Only works from a /live page">use current query</button>159              <button type="submit" className="inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-2 text-[12px] hover:border-line-strong">160                <Plus className="size-3.5" /> Save view161              </button>162            </div>163            {viewErr && <p className="text-[11px] text-danger">{viewErr}</p>}164          </form>165        </Panel>166        <Panel title="Built-in views" dense>167          <ul className="divide-y divide-line">168            {SAVED_VIEWS.map((v) => {169              const q = new URLSearchParams(Object.entries(v.query).map(([k, val]) => [k, String(val)])).toString();170              return (171                <li key={v.key} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]">172                  <Link href={`/live?${q}`} className="min-w-0 truncate hover:underline">{v.label}</Link>173                  <button type="button" onClick={() => setForm({ name: v.label, query: q })} className="shrink-0" title="Copy into the form">174                    <Chip className="hover:border-line-strong">copy</Chip>175                  </button>176                </li>177              );178            })}179          </ul>180        </Panel>181      </aside>182    </div>183  );184}185