Skip to content
Open
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
5 changes: 3 additions & 2 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ export default tseslint.config(
},
},
{
// shadcn-generated components export variants/hooks next to components
files: ["src/components/ui/**/*.tsx"],
// shadcn-generated components export variants/hooks next to components;
// context provider files export the provider component + its hook
files: ["src/components/ui/**/*.tsx", "src/hooks/**/*.tsx"],
rules: {
"react-refresh/only-export-components": "off",
},
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import { KeyRoundIcon } from "lucide-react";

import { ApiKeyDialog } from "@/components/api-key-dialog";
import { Button } from "@/components/ui/button";
import { useApiKey } from "@/hooks/use-api-key";

function App() {
const { setDialogOpen } = useApiKey();

return (
<div className="mx-auto max-w-[1200px] px-8 py-6">
<header className="mb-8">
<header className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Datastream</h1>
<Button
variant="ghost"
size="sm"
onClick={() => setDialogOpen(true)}
className="text-muted-foreground"
>
<KeyRoundIcon /> API key
</Button>
</header>
<main />
<ApiKeyDialog />
</div>
);
}
Expand Down
107 changes: 107 additions & 0 deletions frontend/src/components/api-key-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useApiKey } from "@/hooks/use-api-key";
import { maskApiKey } from "@/lib/api-key";

const formSchema = z.object({
apiKey: z
.string()
.trim()
.regex(/^dsk_/, "keys start with dsk_")
.min(8, "key looks too short"),
});

type FormValues = z.infer<typeof formSchema>;

export function ApiKeyDialog() {
const { apiKey, saveApiKey, clearApiKey, dialogOpen, setDialogOpen } =
useApiKey();
const queryClient = useQueryClient();

const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { apiKey: "" },
});

function onSubmit(values: FormValues) {
saveApiKey(values.apiKey.trim());
form.reset();
setDialogOpen(false);
// refetch everything that failed without a key
void queryClient.invalidateQueries();
}

return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>API key</DialogTitle>
<DialogDescription>
Requests are authenticated with a bearer key. Paste your{" "}
<code className="font-mono">dsk_</code> key — it is stored only in
this browser.
</DialogDescription>
</DialogHeader>
{apiKey && (
<div className="text-muted-foreground flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="font-mono">{maskApiKey(apiKey)}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => clearApiKey()}
>
clear
</Button>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>{apiKey ? "Replace key" : "Key"}</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="dsk_..."
autoComplete="off"
className="font-mono"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">Save</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
68 changes: 68 additions & 0 deletions frontend/src/hooks/use-api-key.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";

import { clearStoredApiKey, getApiKey, storeApiKey } from "@/lib/api-key";

interface ApiKeyContextValue {
apiKey: string | null;
saveApiKey: (key: string) => void;
clearApiKey: () => void;
dialogOpen: boolean;
setDialogOpen: (open: boolean) => void;
}

const ApiKeyContext = createContext<ApiKeyContextValue | null>(null);

// module-level hook so non-react code (the query client's error handler)
// can pop the key dialog
let openDialogListener: (() => void) | null = null;

export function requestApiKey(): void {
openDialogListener?.();
}

export function ApiKeyProvider({ children }: { children: ReactNode }) {
const [apiKey, setApiKey] = useState<string | null>(() => getApiKey());
// auto-open on first load when no key is stored: the backend rejects
// everything except /status without one
const [dialogOpen, setDialogOpen] = useState<boolean>(() => !getApiKey());

useEffect(() => {
openDialogListener = () => setDialogOpen(true);
return () => {
openDialogListener = null;
};
}, []);

const saveApiKey = useCallback((key: string) => {
storeApiKey(key);
setApiKey(key);
}, []);

const clearApiKey = useCallback(() => {
clearStoredApiKey();
setApiKey(null);
}, []);

return (
<ApiKeyContext.Provider
value={{ apiKey, saveApiKey, clearApiKey, dialogOpen, setDialogOpen }}
>
{children}
</ApiKeyContext.Provider>
);
}

export function useApiKey(): ApiKeyContextValue {
const ctx = useContext(ApiKeyContext);
if (!ctx) {
throw new Error("useApiKey must be used inside ApiKeyProvider");
}
return ctx;
}
18 changes: 18 additions & 0 deletions frontend/src/lib/api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const STORAGE_KEY = "datastream.api_key";

export function getApiKey(): string | null {
return localStorage.getItem(STORAGE_KEY);
}

export function storeApiKey(key: string): void {
localStorage.setItem(STORAGE_KEY, key);
}

export function clearStoredApiKey(): void {
localStorage.removeItem(STORAGE_KEY);
}

/** masked display form of a stored key, e.g. "dsk_****cd12" */
export function maskApiKey(key: string): string {
return `${key.slice(0, 4)}****${key.slice(-4)}`;
}
68 changes: 68 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { getApiKey } from "@/lib/api-key";

// dev: unset → relative /api/v1 → vite proxy. prod: absolute url baked at build time
const BASE = `${import.meta.env.VITE_API_BASE_URL ?? ""}/api/v1`;

export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}

export interface DatasetSummary {
name: string;
version: string;
has_data: boolean;
}

export interface DataRow {
timestamp: string;
data: Record<string, unknown>[];
}

export interface DataResponse {
dataset_name: string;
dataset_version: string;
total_timestamps: number;
returned_timestamps: number;
rows: DataRow[];
}

async function apiFetch<T>(
path: string,
params?: Record<string, string>,
okStatuses: number[] = [200],
): Promise<T> {
const query = params ? `?${new URLSearchParams(params)}` : "";
const key = getApiKey();
const res = await fetch(`${BASE}${path}${query}`, {
headers: key ? { Authorization: `Bearer ${key}` } : {},
});
if (!okStatuses.includes(res.status)) {
throw new ApiError(res.status, `request to ${path} failed: ${res.status}`);
}
return res.json() as Promise<T>;
}

export async function fetchDatasets(): Promise<DatasetSummary[]> {
const body = await apiFetch<{ datasets: DatasetSummary[] }>("/datasets");
return body.datasets;
}

// 206 = partial data, still a valid read; build-data=false so browsing never triggers builds
export function fetchData(
name: string,
version: string,
start: string,
end: string,
): Promise<DataResponse> {
return apiFetch<DataResponse>(
`/data/${encodeURIComponent(name)}/${encodeURIComponent(version)}`,
{ start, end, "build-data": "false" },
[200, 206],
);
}
22 changes: 22 additions & 0 deletions frontend/src/lib/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function formatTimestamp(isoString: string): string {
return new Date(isoString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}

export function toISODate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

/** default fetch window: today minus 5 years → today */
export function defaultDateRange(): { start: string; end: string } {
const end = new Date();
const start = new Date(end);
start.setFullYear(start.getFullYear() - 5);
return { start: toISODate(start), end: toISODate(end) };
}
41 changes: 40 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,51 @@
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { toast } from "sonner";

import App from "@/App";
import { Toaster } from "@/components/ui/sonner";
import { ApiKeyProvider, requestApiKey } from "@/hooks/use-api-key";
import { ApiError } from "@/lib/api";

import "@/index.css";

const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (error instanceof ApiError && error.status === 401) {
toast.error("invalid or missing API key");
requestApiKey();
}
},
}),
defaultOptions: {
queries: {
// client errors won't fix themselves on retry
retry: (failureCount, error) => {
if (
error instanceof ApiError &&
[401, 403, 404].includes(error.status)
) {
return false;
}
return failureCount < 2;
},
},
},
});

createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<ApiKeyProvider>
<App />
<Toaster />
</ApiKeyProvider>
</QueryClientProvider>
</StrictMode>,
);
Loading