diff --git a/crates/lance-context-master/ui/index.html b/crates/lance-context-master/ui/index.html index 908075d6..a04a230c 100644 --- a/crates/lance-context-master/ui/index.html +++ b/crates/lance-context-master/ui/index.html @@ -3,7 +3,8 @@ - lance-context master + + lance-context · control plane
diff --git a/crates/lance-context-master/ui/src/App.tsx b/crates/lance-context-master/ui/src/App.tsx index 308629a2..3ae36825 100644 --- a/crates/lance-context-master/ui/src/App.tsx +++ b/crates/lance-context-master/ui/src/App.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { compactionStatus, getExperiment, @@ -10,28 +10,97 @@ import { } from "./api"; import { useUiStore } from "./store"; +/* ---- formatting helpers -------------------------------------------------- */ + +function fmtInt(n: number): string { + return n.toLocaleString("en-US"); +} + +function fmtCompact(n: number): string { + if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B"; + if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K"; + return String(n); +} + function fmtTime(ms: number | null | undefined): string { if (!ms || ms < 0) return "—"; - return new Date(ms).toLocaleString(); + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function relTime(ms: number | null | undefined): string { + if (!ms || ms < 0) return "never"; + const d = Date.now() - ms; + const s = Math.floor(d / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +/* ---- icons --------------------------------------------------------------- */ + +function SearchIcon() { + return ( + + + + + ); +} + +function CloseIcon() { + return ( + + + + ); } -function statusLabel(s: CompactJobStatus | undefined): string { - if (!s) return ""; - switch (s.state) { +/* ---- compaction status --------------------------------------------------- */ + +function StatusPill({ status }: { status: CompactJobStatus | undefined }) { + if (!status || status.state === "none") return null; + switch (status.state) { case "queued": - return "queued"; + return ( + + + queued + + ); case "running": - return "running…"; + return ( + + + running + + ); case "done": - return `done (-${s.fragments_removed}/+${s.fragments_added})`; + return ( + + −{status.fragments_removed} / +{status.fragments_added} + + ); case "failed": - return `failed: ${s.error}`; - case "none": - return ""; + return ( + + + failed + + ); } } -function CompactButton({ name }: { name: string }) { +/** Trigger + live status for one experiment's compaction job. */ +function useCompaction(name: string) { const qc = useQueryClient(); const [poll, setPoll] = useState(false); const status = useQuery({ @@ -48,149 +117,255 @@ function CompactButton({ name }: { name: string }) { }); const s = status.data; - // Stop polling once we reach a terminal state and refresh the list metrics. - if (poll && (s?.state === "done" || s?.state === "failed")) { - setPoll(false); - qc.invalidateQueries({ queryKey: ["experiments"] }); - } + useEffect(() => { + if (poll && (s?.state === "done" || s?.state === "failed")) { + setPoll(false); + qc.invalidateQueries({ queryKey: ["experiments"] }); + qc.invalidateQueries({ queryKey: ["experiment", name] }); + } + }, [poll, s?.state, qc, name]); + const busy = trigger.isPending || s?.state === "running" || s?.state === "queued"; + return { status: s, trigger, busy }; +} + +function CompactButton({ name, variant }: { name: string; variant?: "accent" }) { + const { status, trigger, busy } = useCompaction(name); return ( - + - - {statusLabel(s)} - + ); } -function ExperimentRow({ exp }: { exp: ExperimentSummary }) { +/* ---- table --------------------------------------------------------------- */ + +function Row({ exp }: { exp: ExperimentSummary }) { const select = useUiStore((s) => s.select); + const hot = exp.fragment_count >= 16; return ( - (e.preventDefault(), select(exp.name))}> + - {exp.row_count.toLocaleString()} - {exp.fragment_count.toLocaleString()} - {exp.pending_wal_generations} - {fmtTime(exp.last_updated)} - {fmtTime(exp.last_compaction)} - + {fmtInt(exp.row_count)} + {fmtInt(exp.fragment_count)} + {exp.pending_wal_generations} + {relTime(exp.last_updated)} + {relTime(exp.last_compaction)} + ); } -function DetailPanel({ name }: { name: string }) { +/* ---- detail drawer ------------------------------------------------------- */ + +function Metric({ label, value, wide }: { label: string; value: string; wide?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Drawer({ name }: { name: string }) { const select = useUiStore((s) => s.select); const detail = useQuery({ queryKey: ["experiment", name], queryFn: () => getExperiment(name, true), }); const d = detail.data; + + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && select(null); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [select]); + return ( -
- -

{name}

- {detail.isLoading &&

loading…

} - {d && ( -
- - - - - - - - + <> +
select(null)} /> +
+ +
+ {detail.isLoading && ( +
+ + loading experiment… +
+ )} + {d && ( + <> +
Storage
+
+ + + + + + + +
+ + )} +
+ +
+ +
+ + ); } -function Metric({ label, value }: { label: string; value: string }) { +/* ---- stat strip ---------------------------------------------------------- */ + +function StatCard({ label, value, unit }: { label: string; value: string; unit?: string }) { return ( -
-
{label}
-
{value}
+
+
{label}
+
+ {value} + {unit && {unit}} +
); } +/* ---- app ----------------------------------------------------------------- */ + export function App() { const { search, page, pageSize, selected, setSearch, setPage } = useUiStore(); const list = useQuery({ queryKey: ["experiments", search, page, pageSize], queryFn: () => listExperiments(search, pageSize, page * pageSize), + placeholderData: (prev) => prev, }); const total = list.data?.total ?? 0; const maxPage = Math.max(0, Math.ceil(total / pageSize) - 1); + const rows = list.data?.experiments ?? []; + + // Aggregate the visible page for the stat strip. + const pageRows = rows.reduce((a, e) => a + e.row_count, 0); + const pageFrags = rows.reduce((a, e) => a + e.fragment_count, 0); + const hot = rows.filter((e) => e.fragment_count >= 16).length; return ( -
-

lance-context master

- - {selected && } - -
- setSearch(e.target.value)} - style={{ padding: 6, width: 320 }} - /> - {total} experiments +
+
+
+
+ lance-context + control plane +
+
+
+ + {list.isFetching ? "syncing" : "live"} +
+
+ +
+ + + +
- {list.isLoading &&

loading…

} - {list.isError &&

{String(list.error)}

} - - {list.data && ( - - - - - - - - - - - - - - {list.data.experiments.map((exp) => ( - - ))} - -
namerowsfragmentspendinglast updatedlast compactionaction
- )} +
+
+ + setSearch(e.target.value)} + /> +
+
+ {fmtInt(total)} total +
-
- - - page {page + 1} / {maxPage + 1} - - +
+ {list.isError &&
{String(list.error)}
} + {!list.isError && ( + + + + + + + + + + + + + + {list.isLoading && + Array.from({ length: 6 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((__, j) => ( + + ))} + + ))} + {!list.isLoading && rows.map((exp) => )} + +
ExperimentRowsFragmentsPendingUpdatedCompactedAction
+
+
+ )} + {!list.isLoading && !list.isError && rows.length === 0 && ( +
+ No experiments{search ? ` matching “${search}”` : ""}. +
+ )}
+ + {maxPage > 0 && ( +
+ + page {page + 1} / {maxPage + 1} + + + +
+ )} + + {selected && }
); } diff --git a/crates/lance-context-master/ui/src/main.tsx b/crates/lance-context-master/ui/src/main.tsx index e479872d..8965f80d 100644 --- a/crates/lance-context-master/ui/src/main.tsx +++ b/crates/lance-context-master/ui/src/main.tsx @@ -1,6 +1,7 @@ import ReactDOM from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "./App"; +import "./styles.css"; const queryClient = new QueryClient({ defaultOptions: { diff --git a/crates/lance-context-master/ui/src/styles.css b/crates/lance-context-master/ui/src/styles.css new file mode 100644 index 00000000..09ba6236 --- /dev/null +++ b/crates/lance-context-master/ui/src/styles.css @@ -0,0 +1,643 @@ +/* ============================================================================ + lance-context master — design system + A restrained, data-dense control-plane UI in the register of a frontier AI + lab: near-black canvas, hairline borders, monospace numerics, a single + electric accent, and typography that gets out of the way of the data. + ========================================================================== */ + +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); + +:root { + /* Surfaces — layered near-blacks with a faint blue cast. */ + --bg: #08090c; + --bg-elev: #0e1015; + --bg-elev-2: #14161d; + --bg-hover: #171a22; + --panel: #0c0e13; + + /* Hairlines. */ + --border: #1c1f27; + --border-strong: #2a2e39; + + /* Text. */ + --text: #e8eaed; + --text-muted: #9aa0aa; + --text-dim: #5f656f; + + /* Accent — electric indigo/cyan, used sparingly for signal. */ + --accent: #6d7cff; + --accent-hover: #8590ff; + --accent-dim: rgba(109, 124, 255, 0.12); + --accent-border: rgba(109, 124, 255, 0.35); + + /* Status. */ + --ok: #3fb950; + --ok-dim: rgba(63, 185, 80, 0.12); + --warn: #d29922; + --warn-dim: rgba(210, 153, 34, 0.12); + --err: #f85149; + --err-dim: rgba(248, 81, 73, 0.12); + --run: #58a6ff; + --run-dim: rgba(88, 166, 255, 0.12); + + --radius: 8px; + --radius-sm: 6px; + --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --sans: "Inter", system-ui, -apple-system, sans-serif; + + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background: + radial-gradient(1200px 600px at 80% -10%, rgba(109, 124, 255, 0.06), transparent 60%), + var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +::selection { + background: var(--accent-dim); +} + +/* Scrollbar. */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 8px; + border: 2px solid var(--bg); +} +::-webkit-scrollbar-thumb:hover { + background: #363b47; +} + +/* ---- Layout ------------------------------------------------------------- */ + +.app { + max-width: 1240px; + margin: 0 auto; + padding: 0 28px 80px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 14px; + padding: 18px 0 16px; + margin-bottom: 24px; + background: linear-gradient(var(--bg) 70%, transparent); + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 11px; +} + +.brand__mark { + width: 26px; + height: 26px; + border-radius: 7px; + background: linear-gradient(135deg, var(--accent), #4a56d6); + box-shadow: 0 0 0 1px var(--accent-border), 0 4px 14px rgba(109, 124, 255, 0.35); + position: relative; + flex: none; +} +.brand__mark::after { + content: ""; + position: absolute; + inset: 7px; + border-radius: 3px; + border: 1.5px solid rgba(255, 255, 255, 0.9); + border-bottom-color: transparent; + border-right-color: transparent; +} + +.brand__name { + font-weight: 600; + letter-spacing: -0.01em; + font-size: 15px; +} +.brand__sub { + color: var(--text-dim); + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 7px; + border: 1px solid var(--border); + border-radius: 5px; +} + +.topbar__spacer { + flex: 1; +} + +.live { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-muted); + font-size: 12px; + font-family: var(--mono); +} +.live__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--ok); + box-shadow: 0 0 0 3px var(--ok-dim); + animation: pulse 2s ease-in-out infinite; +} +@keyframes pulse { + 50% { + opacity: 0.45; + } +} + +/* ---- Stat strip --------------------------------------------------------- */ + +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 22px; +} + +.stat { + background: linear-gradient(180deg, var(--bg-elev), var(--panel)); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; +} +.stat__label { + color: var(--text-dim); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 8px; +} +.stat__value { + font-family: var(--mono); + font-size: 22px; + font-weight: 500; + letter-spacing: -0.02em; +} +.stat__value .unit { + color: var(--text-dim); + font-size: 13px; + margin-left: 4px; +} + +/* ---- Toolbar ------------------------------------------------------------ */ + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.search { + position: relative; + flex: 1; + max-width: 420px; +} +.search svg { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-dim); + pointer-events: none; +} +.search input { + width: 100%; + padding: 9px 12px 9px 36px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--sans); + font-size: 13px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.search input::placeholder { + color: var(--text-dim); +} +.search input:focus { + outline: none; + border-color: var(--accent-border); + box-shadow: 0 0 0 3px var(--accent-dim); +} + +.toolbar__count { + color: var(--text-muted); + font-size: 12px; + font-family: var(--mono); +} + +/* ---- Table -------------------------------------------------------------- */ + +.table-wrap { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: var(--panel); + box-shadow: var(--shadow); +} + +table.grid { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +table.grid thead th { + text-align: left; + padding: 11px 16px; + background: var(--bg-elev); + border-bottom: 1px solid var(--border); + color: var(--text-dim); + font-weight: 500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: nowrap; +} +table.grid th.num, +table.grid td.num { + text-align: right; + font-family: var(--mono); +} + +table.grid tbody td { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + vertical-align: middle; + white-space: nowrap; +} +table.grid tbody tr:last-child td { + border-bottom: none; +} +table.grid tbody tr { + transition: background 0.12s; +} +table.grid tbody tr:hover { + background: var(--bg-hover); +} + +.name-cell { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); + font-weight: 500; + cursor: pointer; + background: none; + border: none; + padding: 0; + font-family: var(--sans); + font-size: 13px; +} +.name-cell:hover { + color: var(--accent-hover); +} +.name-cell::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 2px; + background: var(--accent); + opacity: 0.55; + flex: none; +} + +td .muted { + color: var(--text-dim); + font-family: var(--mono); + font-size: 12px; +} + +/* Fragment health tint: hot fragments read amber. */ +.frag--hot { + color: var(--warn); +} + +/* ---- Buttons ------------------------------------------------------------ */ + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--bg-elev-2); + color: var(--text); + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.13s, border-color 0.13s, transform 0.05s; + white-space: nowrap; +} +.btn:hover:not(:disabled) { + background: var(--bg-hover); + border-color: #363b47; +} +.btn:active:not(:disabled) { + transform: translateY(1px); +} +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.btn--accent { + background: var(--accent-dim); + border-color: var(--accent-border); + color: var(--accent-hover); +} +.btn--accent:hover:not(:disabled) { + background: rgba(109, 124, 255, 0.18); + border-color: var(--accent); +} +.btn--ghost { + background: transparent; + border-color: var(--border); +} + +/* ---- Status pills ------------------------------------------------------- */ + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 999px; + font-family: var(--mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.01em; + border: 1px solid transparent; +} +.pill__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.pill--queued { + color: var(--text-muted); + background: var(--bg-elev-2); + border-color: var(--border-strong); +} +.pill--running { + color: var(--run); + background: var(--run-dim); + border-color: rgba(88, 166, 255, 0.3); +} +.pill--running .pill__dot { + animation: pulse 1.1s ease-in-out infinite; +} +.pill--done { + color: var(--ok); + background: var(--ok-dim); + border-color: rgba(63, 185, 80, 0.3); +} +.pill--failed { + color: var(--err); + background: var(--err-dim); + border-color: rgba(248, 81, 73, 0.3); +} + +/* ---- Pagination --------------------------------------------------------- */ + +.pager { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + margin-top: 16px; +} +.pager__info { + color: var(--text-muted); + font-family: var(--mono); + font-size: 12px; +} + +/* ---- Detail drawer ------------------------------------------------------ */ + +.scrim { + position: fixed; + inset: 0; + background: rgba(4, 5, 8, 0.6); + backdrop-filter: blur(2px); + z-index: 40; + animation: fade 0.16s ease; +} +@keyframes fade { + from { + opacity: 0; + } +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(520px, 92vw); + background: var(--panel); + border-left: 1px solid var(--border-strong); + z-index: 50; + box-shadow: -20px 0 60px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + animation: slide 0.22s cubic-bezier(0.16, 1, 0.3, 1); +} +@keyframes slide { + from { + transform: translateX(24px); + opacity: 0; + } +} + +.drawer__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 20px 22px 16px; + border-bottom: 1px solid var(--border); +} +.drawer__title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + word-break: break-all; + display: flex; + align-items: center; + gap: 9px; +} +.drawer__title .glyph { + width: 8px; + height: 8px; + border-radius: 2px; + background: var(--accent); + flex: none; +} +.drawer__uri { + color: var(--text-dim); + font-family: var(--mono); + font-size: 11px; + margin-top: 4px; + word-break: break-all; +} +.iconbtn { + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + border-radius: var(--radius-sm); + width: 30px; + height: 30px; + display: grid; + place-items: center; + cursor: pointer; + flex: none; + transition: background 0.13s, color 0.13s; +} +.iconbtn:hover { + background: var(--bg-hover); + color: var(--text); +} + +.drawer__body { + padding: 20px 22px; + overflow-y: auto; + flex: 1; +} + +.metric-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin-bottom: 20px; +} +.metric { + background: var(--bg-elev); + padding: 14px 16px; +} +.metric__label { + color: var(--text-dim); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; +} +.metric__value { + font-family: var(--mono); + font-size: 16px; + font-weight: 500; + word-break: break-all; +} +.metric--wide { + grid-column: 1 / -1; +} +.metric--wide .metric__value { + font-size: 12px; + color: var(--text-muted); +} + +.drawer__actions { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 22px; + border-top: 1px solid var(--border); +} + +.section-label { + color: var(--text-dim); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 4px 0 10px; +} + +/* ---- States ------------------------------------------------------------- */ + +.empty, +.loading, +.error { + padding: 56px 20px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} +.error { + color: var(--err); + font-family: var(--mono); + font-size: 12px; +} +.spinner { + display: inline-block; + width: 15px; + height: 15px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + vertical-align: -3px; + margin-right: 8px; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.skeleton { + height: 13px; + border-radius: 4px; + background: linear-gradient(90deg, var(--bg-elev) 25%, var(--bg-elev-2) 50%, var(--bg-elev) 75%); + background-size: 200% 100%; + animation: shimmer 1.3s infinite; +} +@keyframes shimmer { + to { + background-position: -200% 0; + } +} + +@media (max-width: 720px) { + .stats { + grid-template-columns: repeat(2, 1fr); + } +}