diff --git a/.github/workflows/sdk-python.yml b/.github/workflows/sdk-python.yml new file mode 100644 index 0000000..f4bf07b --- /dev/null +++ b/.github/workflows/sdk-python.yml @@ -0,0 +1,86 @@ +name: sdk-python + +# Two triggers: +# - push to main touching sdks/python/** → runs the test job only. +# - a git tag of the form sdk-python-vX.Y.Z → runs tests AND publishes +# to PyPI via trusted publishing (OIDC, no long-lived token). +# +# Tag convention: ``git tag sdk-python-v0.1.0 && git push origin +# sdk-python-v0.1.0``. The version in the tag must match the version in +# pyproject.toml; the publish step fails fast if they disagree. + +on: + push: + branches: [main] + paths: + - "sdks/python/**" + - ".github/workflows/sdk-python.yml" + tags: + - "sdk-python-v*" + pull_request: + paths: + - "sdks/python/**" + - ".github/workflows/sdk-python.yml" + +concurrency: + group: sdk-python-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + defaults: + run: + working-directory: sdks/python + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install --upgrade pip + - run: pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Test + run: pytest -q + + publish: + if: startsWith(github.ref, 'refs/tags/sdk-python-v') + needs: test + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdks/python + # Trusted publishing — configure once on PyPI: + # https://docs.pypi.org/trusted-publishers/ + # Repository owner + name + workflow filename + environment all must + # match what's configured there. No secrets live in the repo. + environment: pypi-release + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build twine + - name: Verify tag matches pyproject version + run: | + TAG_VERSION="${GITHUB_REF_NAME#sdk-python-v}" + PKG_VERSION="$(python -c 'import tomllib; print(tomllib.loads(open("pyproject.toml","rb").read().decode())["project"]["version"])')" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version $TAG_VERSION does not match pyproject version $PKG_VERSION" + exit 1 + fi + - name: Build + run: python -m build + - name: Check + run: twine check dist/* + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdks/python/dist diff --git a/.github/workflows/sdk-typescript.yml b/.github/workflows/sdk-typescript.yml new file mode 100644 index 0000000..9f79967 --- /dev/null +++ b/.github/workflows/sdk-typescript.yml @@ -0,0 +1,82 @@ +name: sdk-typescript + +# Two triggers: +# - push / PR touching sdks/typescript/** → test + typecheck. +# - tag sdk-typescript-vX.Y.Z → tests then publishes to npm with +# provenance (SLSA-style attestation visible on the npm page). +# +# Tag convention: ``git tag sdk-typescript-v0.1.0 && git push origin +# sdk-typescript-v0.1.0``. The tag version must match package.json; the +# publish job fails fast if they disagree. + +on: + push: + branches: [main] + paths: + - "sdks/typescript/**" + - ".github/workflows/sdk-typescript.yml" + tags: + - "sdk-typescript-v*" + pull_request: + paths: + - "sdks/typescript/**" + - ".github/workflows/sdk-typescript.yml" + +concurrency: + group: sdk-typescript-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ["18", "20", "22"] + defaults: + run: + working-directory: sdks/typescript + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: sdks/typescript/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + + publish: + if: startsWith(github.ref, 'refs/tags/sdk-typescript-v') + needs: test + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdks/typescript + # npm provenance requires id-token:write. The NPM_TOKEN secret is a + # classic automation token with publish permission on @opengraph/sdk. + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + cache: npm + cache-dependency-path: sdks/typescript/package-lock.json + - run: npm ci + - name: Verify tag matches package.json version + run: | + TAG_VERSION="${GITHUB_REF_NAME#sdk-typescript-v}" + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version $TAG_VERSION does not match package.json version $PKG_VERSION" + exit 1 + fi + - run: npm run build + - name: Publish to npm with provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public diff --git a/.gitignore b/.gitignore index 7947ec6..878128b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,8 @@ htmlcov/ frontend/node_modules/ frontend/.next/ frontend/tsconfig.tsbuildinfo + +# SDK build artifacts +sdks/typescript/node_modules/ +sdks/typescript/dist/ +sdks/python/.pytest_cache/ diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d6c47f0 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,56 @@ +# Alembic configuration for OpenGraph. +# +# The legacy idempotent ALTER TABLE IF NOT EXISTS bootstrap in +# ``src/infra/db.py::init_db`` still runs on every startup for zero-downtime +# dev ergonomics. Alembic is the strict, versioned migration path for prod: +# +# alembic upgrade head # apply pending migrations +# alembic revision --autogenerate -m "add_foo_column" +# alembic downgrade -1 # rollback one step +# alembic stamp head # mark DB at HEAD without running migrations +# # (first-time adoption on an existing DB) +# +# env.py reads DATABASE_URL from src.config so the same URL drives both the +# runtime engine and the migration engine. + +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README.md b/alembic/README.md new file mode 100644 index 0000000..9094f87 --- /dev/null +++ b/alembic/README.md @@ -0,0 +1,75 @@ +# Alembic migrations + +OpenGraph uses Alembic for versioned schema changes. The legacy idempotent +`ALTER TABLE IF NOT EXISTS` bootstrap in `src/infra/db.py::init_db` still +runs on every startup — that path is fine for dev but not safe for +1M-row alterations in prod. + +## First-time adoption on an existing database + +If Neon already holds the current schema (everything `init_db` creates), +stamp the DB at HEAD so Alembic doesn't try to re-create tables: + +```bash +alembic stamp head +``` + +That writes a row into `alembic_version` and skips over the existing +schema. From there forward, every schema change is a migration. + +## Creating a new migration + +```bash +alembic revision --autogenerate -m "add_my_column" +# review the generated versions/*.py, tweak the batching / defaults +alembic upgrade head +``` + +Autogenerate picks up new/changed columns, indexes and constraints from +`src.infra.db_models.Base.metadata`. It will *not* detect: + +- Server-side DEFAULT changes that aren't part of the ORM default. +- Data backfills (you have to write them by hand in `upgrade()`). +- Check constraint wording changes. + +Always re-read the diff before applying. + +## Safe patterns for 1M-row tables + +Adding a NOT NULL column with a default: + +```python +def upgrade() -> None: + op.add_column("workspaces", sa.Column("plan_tier", sa.String(16), nullable=True)) + op.execute("UPDATE workspaces SET plan_tier = 'trial' WHERE plan_tier IS NULL") + op.alter_column("workspaces", "plan_tier", nullable=False) +``` + +Three small transactions beat one giant ALTER that locks writes for +minutes. + +Adding an index on a hot table: + +```python +def upgrade() -> None: + op.create_index( + "ix_chat_messages_session", + "chat_messages", + ["session_id"], + postgresql_concurrently=True, + ) +``` + +`CONCURRENTLY` keeps writes unblocked; the trade-off is you can't wrap +it in a transaction (Alembic handles this automatically when the flag +is set). + +## Downgrades + +```bash +alembic downgrade -1 +``` + +Write `downgrade()` to drop whatever `upgrade()` added. For destructive +migrations (data deletion, column drops) leave a comment making the +point of no return obvious. diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..054839d --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,112 @@ +"""Alembic environment. + +Reads DATABASE_URL from ``src.config`` so operators don't have to duplicate +secrets between ``.env`` and ``alembic.ini``. Runs migrations via the same +asyncpg driver the runtime uses — no second Postgres DBAPI dependency. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +# Make ``src`` importable when alembic is invoked from repo root. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_HERE) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from src.config import DATABASE_URL # noqa: E402 +from src.infra.db_models import Base # noqa: E402 — registers models on Base.metadata + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +def _async_url(url: str) -> str: + """Ensure the URL uses the asyncpg driver that the runtime engine uses.""" + if not url: + return url + if url.startswith("postgresql+asyncpg://"): + return url + if url.startswith("postgresql://"): + return "postgresql+asyncpg://" + url[len("postgresql://"):] + if url.startswith("postgres://"): + return "postgresql+asyncpg://" + url[len("postgres://"):] + return url + + +_RESOLVED_URL = _async_url(DATABASE_URL) +if _RESOLVED_URL: + config.set_main_option("sqlalchemy.url", _RESOLVED_URL) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode against a URL, not a live engine.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online_async() -> None: + """Run migrations in 'online' mode using an asyncpg-backed engine.""" + # asyncpg doesn't understand libpq-only query params. + import re + cfg = config.get_section(config.config_ini_section, {}) + url = cfg.get("sqlalchemy.url", "") + for key in ("sslmode", "channel_binding", "pgbouncer"): + url = re.sub(rf"[?&]{key}=[^&]*", "", url) + if url.endswith("?"): + url = url[:-1] + cfg["sqlalchemy.url"] = url + # Neon requires SSL — match the runtime engine's hint. + connect_args = {"ssl": "require"} if "neon.tech" in url else {} + + connectable = async_engine_from_config( + cfg, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + connect_args=connect_args, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_migrations_online_async()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..ee746cf --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/9b258738389c_baseline.py b/alembic/versions/9b258738389c_baseline.py new file mode 100644 index 0000000..cea7d78 --- /dev/null +++ b/alembic/versions/9b258738389c_baseline.py @@ -0,0 +1,28 @@ +"""baseline + +Revision ID: 9b258738389c +Revises: +Create Date: 2026-04-23 12:30:11.143168 + +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9b258738389c' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/frontend/app/api-docs/page.tsx b/frontend/app/api-docs/page.tsx new file mode 100644 index 0000000..dd71357 --- /dev/null +++ b/frontend/app/api-docs/page.tsx @@ -0,0 +1,366 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + BookOpen, + ExternalLink, + KeyRound, + Package, + Plus, + ShieldCheck, + Zap, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { CodeSnippet } from "@/components/api-docs/code-snippet"; +import { api } from "@/lib/api"; + +const DEFAULT_KEY_PLACEHOLDER = "og_live_"; +const DEFAULT_WS_PLACEHOLDER = ""; + +type Tab = "curl" | "python" | "typescript" | "node"; + +export default function ApiDocsPage() { + const base = typeof window !== "undefined" + ? `${window.location.protocol}//${window.location.hostname}:8000` + : "https://api.opengraph.example"; + + const keysQuery = useQuery({ queryKey: ["api-keys"], queryFn: api.listApiKeys }); + const workspacesQuery = useQuery({ + queryKey: ["workspaces"], + queryFn: api.listWorkspaces, + }); + + const firstLiveKey = (keysQuery.data ?? []).find((k) => !k.revoked_at); + const firstWorkspace = workspacesQuery.data?.workspaces?.[0]; + + // Keys aren't retrievable in plaintext post-creation. The "working example" + // therefore substitutes the prefix only (``og_live_abcd1234…``) so the + // snippet reads correctly; the user pastes their own secret when running. + const keyExample = firstLiveKey ? `${firstLiveKey.prefix}…` : DEFAULT_KEY_PLACEHOLDER; + const wsExample = firstWorkspace?.id ?? DEFAULT_WS_PLACEHOLDER; + + const [tab, setTab] = React.useState("curl"); + + return ( +
+
+
+

+ API documentation +

+

+ Call your knowledge graphs from any language. Public surface at{" "} + /api/v1/ext/*, authenticated with{" "} + + API keys + + . Full OpenAPI spec:{" "} + + /api/v1/ext/openapi.json + +

+
+
+ + {!firstLiveKey && ( + + )} +
+
+ + + +
+
+

+ Query a graph +

+
+ {(["curl", "python", "typescript", "node"] as Tab[]).map((t) => ( + + ))} +
+
+ +

+ Ask a natural-language question. The assistant replies as markdown.{" "} + {firstLiveKey ? ( + <> + Using key {firstLiveKey.prefix}… + {firstWorkspace ? ( + <> + {" "}and workspace{" "} + {firstWorkspace.name}. + + ) : null} + + ) : ( + <>Replace {DEFAULT_KEY_PLACEHOLDER} with your real key. + )} +

+ + {tab === "curl" && ( + + )} + {tab === "python" && ( + + )} + {tab === "typescript" && ( + + )} + {tab === "node" && ( + = 18, no extra deps +const res = await fetch("${base}/api/v1/ext/query", { + method: "POST", + headers: { + "Authorization": "Bearer ${keyExample}", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + workspace_id: "${wsExample}", + query: "What are the tools used in the payments pipeline?", + }), +}); +if (!res.ok) throw new Error(\`Query failed: \${res.status}\`); +const data = await res.json(); +console.log(data.response);`} + /> + )} +
+ + + + + + + Conventions + + + +

+ Auth.{" "} + Every request carries{" "} + Authorization: Bearer og_live_…. Invalid + or revoked keys return 401. +

+

+ Workspace scope.{" "} + Account-scoped keys require a workspace_id on every call; + workspace-pinned keys may omit it. Mismatches return 404 rather than{" "} + 403. +

+

+ Rate limits.{" "} + Every response carries X-RateLimit-Limit,{" "} + X-RateLimit-Remaining, and X-RateLimit-Reset headers. + Over-quota returns 429 with Retry-After. +

+

+ Errors.{" "} + All error responses are JSON with at least a{" "} + detail field. Network errors and{" "} + 5xx are safe to retry with exponential backoff. +

+

+ Versioning.{" "} + Path is /api/v1/ext/*. Response shape is stable within v1 — + fields may be added, never removed or retyped, without a version bump. +

+
+
+
+ ); +} + +function SetupCards() { + return ( +
+ + + + 1. Mint a key + + + +

+ Go to /api-keys{" "} + and click New key. The plaintext is shown once — copy it + somewhere safe. +

+
+
+ + + + 2. Install an SDK + + + +

+ Official SDKs handle auth, retries, and typed responses. +

+
+ pip install opengraph-sdk + npm i @opengraph/sdk +
+
+
+ + + + 3. Query the graph + + + +

+ Pass workspace_id + query and get back a markdown + answer with follow-up suggestions and token usage. +

+
+
+
+ ); +} + +function EndpointGrid({ + base, + keyExample, + wsExample, +}: { + base: string; + keyExample: string; + wsExample: string; +}) { + const rows = [ + { method: "POST", path: "/api/v1/ext/query", desc: "Ask a natural-language question" }, + { method: "GET", path: "/api/v1/ext/graph/stats", desc: "Node + edge counts by type" }, + { method: "GET", path: "/api/v1/ext/graph/search", desc: "Hybrid keyword + semantic search" }, + { method: "GET", path: "/api/v1/ext/graph/node/{id}", desc: "Node + edges + children + parent" }, + { method: "POST", path: "/api/v1/ext/graph/traverse", desc: "BFS traverse with edge filter" }, + { method: "GET", path: "/api/v1/ext/graph/tree", desc: "Hierarchical navigation tree" }, + { method: "GET", path: "/api/v1/ext/graph/tools", desc: "All extracted Tool nodes" }, + { method: "GET", path: "/api/v1/ext/graph/chapters", desc: "Flat list of chapters" }, + { method: "GET", path: "/api/v1/ext/history/chats", desc: "Recent chat sessions" }, + { method: "GET", path: "/api/v1/ext/history/chats/{id}", desc: "Full message thread" }, + { method: "GET", path: "/api/v1/ext/history/builds", desc: "Recent build jobs" }, + { method: "GET", path: "/api/v1/ext/history/builds/{id}", desc: "Single build status + stats" }, + ]; + + return ( +
+

Endpoints

+
+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
MethodPathDescription
+ + {r.method} + + {r.path}{r.desc}
+
+

+ Full reference with request/response schemas lives in the{" "} + + Swagger UI + + . +

+
+ ); +} diff --git a/frontend/app/api-keys/page.tsx b/frontend/app/api-keys/page.tsx new file mode 100644 index 0000000..d1f568b --- /dev/null +++ b/frontend/app/api-keys/page.tsx @@ -0,0 +1,266 @@ +"use client"; + +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + KeyRound, + Plus, + Trash2, + AlertCircle, + Briefcase, + Clock, + ExternalLink, +} from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { CreateKeyDialog } from "@/components/api-keys/create-key-dialog"; +import { api } from "@/lib/api"; +import type { ApiKeyRow } from "@/lib/schema"; +import { cn } from "@/lib/utils"; + +export default function ApiKeysPage() { + const qc = useQueryClient(); + const [createOpen, setCreateOpen] = React.useState(false); + const [toRevoke, setToRevoke] = React.useState(null); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["api-keys"], + queryFn: api.listApiKeys, + refetchInterval: 30_000, + }); + + const revokeMut = useMutation({ + mutationFn: (id: string) => api.revokeApiKey(id), + onMutate: async (id: string) => { + await qc.cancelQueries({ queryKey: ["api-keys"] }); + const prev = qc.getQueryData(["api-keys"]); + if (prev) { + qc.setQueryData( + ["api-keys"], + prev.map((k) => + k.id === id ? { ...k, revoked_at: new Date().toISOString() } : k, + ), + ); + } + return { prev }; + }, + onError: (err: Error, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(["api-keys"], ctx.prev); + toast.error(err.message); + }, + onSuccess: () => toast.success("Key revoked"), + onSettled: () => qc.invalidateQueries({ queryKey: ["api-keys"] }), + }); + + const keys = data ?? []; + const live = keys.filter((k) => !k.revoked_at); + const revoked = keys.filter((k) => k.revoked_at); + + return ( +
+
+
+

+ API keys +

+

+ Developer credentials for the public /api/v1/ext/* surface. + Keys are shown once at creation; lost secrets can only be replaced by revoking and + minting a new one.{" "} + + View the docs → + +

+
+ +
+ + {isError && ( + + + + {(error as Error).message} + + + )} + + {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : keys.length === 0 ? ( + + + +
+

No API keys yet

+

+ Mint one to start calling the API from scripts, SDKs, or CI. +

+
+ +
+
+ ) : ( +
+ {live.map((k) => ( + setToRevoke(k)} /> + ))} + {revoked.length > 0 && ( + <> +

+ Revoked +

+ {revoked.map((k) => ( + + ))} + + )} +
+ )} + + + + !v && setToRevoke(null)}> + + + Revoke "{toRevoke?.name}"? + + Revocation is effective within ~5 minutes across all workers. + In-flight requests using this key complete normally. + You can't un-revoke — create a fresh key if you need to roll back. + + + + + + + + +
+ ); +} + +function KeyRow({ + k, + onRevoke, +}: { + k: ApiKeyRow; + onRevoke: (() => void) | null; +}) { + const isRevoked = Boolean(k.revoked_at); + return ( +
+
+ +
+
+
+

{k.name}

+ + {k.prefix}… + + {isRevoked && ( + + revoked + + )} +
+
+ {k.workspace_id ? ( + + + {k.workspace_id.slice(0, 8)}… + + ) : ( + account-scoped + )} + · + {k.rate_limit_rpm} rpm + · + + + {k.last_used_at + ? `last used ${relativeTime(k.last_used_at)}` + : "never used"} + +
+
+ {onRevoke && ( + + )} +
+ ); +} + +function relativeTime(iso: string): string { + const t = new Date(iso).getTime(); + const diff = Date.now() - t; + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return new Date(iso).toLocaleDateString(); +} diff --git a/frontend/app/templates/page.tsx b/frontend/app/templates/page.tsx index 679d51d..b1bfd79 100644 --- a/frontend/app/templates/page.tsx +++ b/frontend/app/templates/page.tsx @@ -8,6 +8,7 @@ import { api } from "@/lib/api"; import { errorMessage } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { TemplateCard } from "@/components/templates/template-card"; import { TemplateFormDialog } from "@/components/templates/template-form-dialog"; import { @@ -141,21 +142,23 @@ export default function TemplatesPage() { -
-
+
+
setQuery(e.target.value)} - placeholder="Search templates…" - className="pl-9" + placeholder="Search templates by name, domain, or category…" + className="pl-9 h-10" />
)} + {!templatesQuery.isLoading && ( +

+ {all.length} template{all.length === 1 ? "" : "s"} + {category ? ` in ${category}` : ""} + {query ? ` matching "${query}"` : ""} +

+ )} + {templatesQuery.isLoading ? ( -

Loading catalog…

+
+ {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ( +
+ +
+ + +
+ + +
+ ))} +
) : all.length === 0 ? ( -

- No templates match that search. -

+
+

No templates match that search.

+

+ Try a different keyword or clear the category filter. +

+
) : ( -
+
{all.map((t) => ( api.deleteWorkspaceFile(ws, id), - onSuccess: () => qc.invalidateQueries({ queryKey: ["workspace-files", activeWs] }), - onError: (err: Error) => toast.error(err.message), + onMutate: async ({ id }) => { + const key = ["workspace-files", activeWs] as const; + await qc.cancelQueries({ queryKey: key }); + const prev = qc.getQueryData<{ files: Array<{ id: number }> }>(key); + if (prev) { + qc.setQueryData(key, { ...prev, files: prev.files.filter((f) => f.id !== id) }); + } + return { prev }; + }, + onError: (err: Error, _vars, ctx) => { + if (ctx?.prev) qc.setQueryData(["workspace-files", activeWs], ctx.prev); + toast.error(err.message); + }, + onSettled: () => qc.invalidateQueries({ queryKey: ["workspace-files", activeWs] }), }); const uploadMut = useMutation({ diff --git a/frontend/app/workspaces/page.tsx b/frontend/app/workspaces/page.tsx index 217b282..c6392fc 100644 --- a/frontend/app/workspaces/page.tsx +++ b/frontend/app/workspaces/page.tsx @@ -6,7 +6,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Plus, - Briefcase, ArrowRight, Trash2, FileJson, @@ -20,18 +19,17 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { CreateWorkspaceDialog } from "@/components/workspace/create-workspace-dialog"; import { api } from "@/lib/api"; import type { WorkspaceSummary } from "@/lib/schema"; import { useWorkspaceStore } from "@/store/workspace-store"; @@ -49,31 +47,31 @@ export default function WorkspacesPage() { }); const [open, setOpen] = React.useState(false); - const [name, setName] = React.useState(""); - const [description, setDescription] = React.useState(""); - - const createMut = useMutation({ - mutationFn: () => api.createWorkspace({ name, description: description || undefined }), - onSuccess: (ws) => { - toast.success(`Workspace "${ws.name}" created`); - qc.invalidateQueries({ queryKey: ["workspaces"] }); - setActiveId(ws.id); - setOpen(false); - setName(""); - setDescription(""); - setTimeout(() => router.push("/upload"), 300); - }, - onError: (err: Error) => toast.error(err.message), - }); + const [toDelete, setToDelete] = React.useState(null); const deleteMut = useMutation({ mutationFn: (id: string) => api.deleteWorkspace(id), - onSuccess: (_, id) => { + onMutate: async (id: string) => { + await qc.cancelQueries({ queryKey: ["workspaces"] }); + const prev = qc.getQueryData<{ workspaces: WorkspaceSummary[] }>(["workspaces"]); + if (prev) { + qc.setQueryData<{ workspaces: WorkspaceSummary[] }>(["workspaces"], { + workspaces: prev.workspaces.filter((w) => w.id !== id), + }); + } + if (id === activeId) setActiveId(null); + return { prev }; + }, + onError: (err: Error, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(["workspaces"], ctx.prev); + toast.error(err.message); + }, + onSuccess: () => { toast.success("Workspace deleted"); + }, + onSettled: () => { qc.invalidateQueries({ queryKey: ["workspaces"] }); - if (id === activeId) setActiveId(null); }, - onError: (err: Error) => toast.error(err.message), }); const rows = data?.workspaces ?? []; @@ -91,52 +89,39 @@ export default function WorkspacesPage() { rerun the build to pick up new changes.

- - + New graph - - - - Create graph - - Name it after the domain it will hold (e.g. "Loan Assessment"). You can rename it later. - - -
{ - e.preventDefault(); - createMut.mutate(); - }} - > -
- - setName(e.target.value)} - placeholder="e.g. Loan assessment" - /> -
-
- -