diff --git a/Makefile b/Makefile
index 4299de0..707151e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,16 @@
# ----------------------------------------------------------------------------
# Kamado Pool — top-level Makefile
# ----------------------------------------------------------------------------
-.PHONY: help ckpool api api-test ckpool-shell up down logs clean
+.PHONY: help ckpool api api-test ckpool-shell ui ui-dev ui-check up down logs clean
help:
@echo "Kamado Pool targets:"
@echo " make ckpool Build the ckpool-solo docker image"
@echo " make api Build the kamado-api docker image"
@echo " make api-test Run Go tests for kamado-api (requires Go installed)"
+ @echo " make ui Build the Svelte UI to ui/dist (requires node)"
+ @echo " make ui-dev Run the Vite dev server on :5173 with /api proxy"
+ @echo " make ui-check Run svelte-check type diagnostics"
@echo " make ckpool-shell Open a shell in the built ckpool image"
@echo " make up docker compose up -d --build"
@echo " make down docker compose down"
@@ -23,6 +26,15 @@ api:
api-test:
cd api && go test ./... -race
+ui:
+ cd ui && npm install && npm run build
+
+ui-dev:
+ cd ui && npm install && npm run dev
+
+ui-check:
+ cd ui && npm install && npm run check
+
ckpool-shell: ckpool
docker run --rm -it --entrypoint /bin/sh kamado/ckpool:dev
diff --git a/README.md b/README.md
index c7df701..cbe00a1 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,8 @@ Three main components:
- [x] Phase 2a: CKPool socket client, bitcoind RPC, state aggregator, REST API
- [x] Phase 2b: CKPool log tailer, block history, stdlib WebSocket push
- [ ] Phase 2b.5: ZMQ block notifier, SQLite persistence (deferred until s9pk repo exists — need real Go build env for new deps)
-- [ ] **Phase 3** — Svelte UI dashboard
+- [x] ckpool patch 0001: expose `bestever` in runtime socket JSON so the UI can show "this round" and "all-time" best share side by side
+- [~] **Phase 3** — Svelte UI dashboard (skeleton: header, pool overview, miners table, blocks, best shares leaderboard; live WS updates)
- [ ] **Phase 4** — Monorepo Docker build, full stack integration
- [ ] **Phase 5** — Testing (regtest, testnet4), polish
diff --git a/ui/.gitignore b/ui/.gitignore
new file mode 100644
index 0000000..b01a18a
--- /dev/null
+++ b/ui/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+.vite/
+*.log
+.DS_Store
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..c05a201
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Kamado Pool
+
+
+
+
+
+
diff --git a/ui/package.json b/ui/package.json
new file mode 100644
index 0000000..b0afe63
--- /dev/null
+++ b/ui/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "kamado-ui",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Kamado Pool dashboard — Svelte 5 + Vite, talks to kamado-api over REST and WebSocket.",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-check --tsconfig ./tsconfig.json"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@tsconfig/svelte": "^5.0.4",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.6.0",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
new file mode 100644
index 0000000..a7249a2
--- /dev/null
+++ b/ui/src/App.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+
+ {#if !snap.data}
+
+
Status
+
Connecting to kamado-api…
+ {#if snap.error}
+
{snap.error}
+ {/if}
+
+ {:else}
+
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/ui/src/app.css b/ui/src/app.css
new file mode 100644
index 0000000..141a96d
--- /dev/null
+++ b/ui/src/app.css
@@ -0,0 +1,151 @@
+:root {
+ color-scheme: dark light;
+ --bg: #0b0e14;
+ --bg-alt: #11151d;
+ --bg-card: #151a24;
+ --bg-hover: #1c2230;
+ --border: #232a3a;
+ --fg: #e6e9ef;
+ --fg-dim: #8a92a6;
+ --accent: #ff7a3a;
+ --accent-dim: #b8501f;
+ --good: #5ce0a8;
+ --bad: #ff6b6b;
+ --warn: #f5c447;
+ font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ color: var(--fg);
+ min-height: 100vh;
+}
+
+body {
+ font-variant-numeric: tabular-nums;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+
+button {
+ font: inherit;
+ color: inherit;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ padding: 0.5em 1em;
+ border-radius: 6px;
+ cursor: pointer;
+}
+button:hover {
+ background: var(--bg-hover);
+}
+
+code,
+.mono {
+ font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular,
+ Menlo, monospace;
+ font-size: 0.92em;
+}
+
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 1.25rem 1.5rem;
+}
+
+.grid {
+ display: grid;
+ gap: 1rem;
+}
+
+.grid-4 {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+.grid-2 {
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
+}
+
+.stat-label {
+ color: var(--fg-dim);
+ font-size: 0.85em;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 0.35em;
+}
+.stat-value {
+ font-size: 1.7rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+.stat-sub {
+ color: var(--fg-dim);
+ font-size: 0.85em;
+ margin-top: 0.25em;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+th,
+td {
+ text-align: left;
+ padding: 0.55rem 0.85rem;
+ border-bottom: 1px solid var(--border);
+}
+th {
+ font-weight: 500;
+ color: var(--fg-dim);
+ font-size: 0.82em;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+tbody tr:hover {
+ background: var(--bg-hover);
+}
+td.num,
+th.num {
+ text-align: right;
+}
+
+.badge {
+ display: inline-block;
+ padding: 0.15em 0.55em;
+ border-radius: 999px;
+ font-size: 0.75em;
+ border: 1px solid var(--border);
+ background: var(--bg-alt);
+}
+.badge.good {
+ color: var(--good);
+ border-color: #2f5c48;
+ background: #0f2419;
+}
+.badge.bad {
+ color: var(--bad);
+ border-color: #5c2f2f;
+ background: #241010;
+}
+.badge.warn {
+ color: var(--warn);
+ border-color: #5c502f;
+ background: #241f10;
+}
diff --git a/ui/src/format.ts b/ui/src/format.ts
new file mode 100644
index 0000000..e41277a
--- /dev/null
+++ b/ui/src/format.ts
@@ -0,0 +1,108 @@
+// Formatters for hashrate, share difficulty, time, and miner hardware
+// detection from stratum user-agent strings. Pure functions, no DOM.
+
+const HASHRATE_UNITS = ["H/s", "kH/s", "MH/s", "GH/s", "TH/s", "PH/s", "EH/s"];
+
+export function formatHashrate(hs: number): string {
+ if (!hs || hs <= 0 || !isFinite(hs)) return "0 H/s";
+ let i = 0;
+ let v = hs;
+ while (v >= 1000 && i < HASHRATE_UNITS.length - 1) {
+ v /= 1000;
+ i++;
+ }
+ const digits = v >= 100 ? 0 : v >= 10 ? 1 : 2;
+ return `${v.toFixed(digits)} ${HASHRATE_UNITS[i]}`;
+}
+
+// Difficulty is a share-diff value — scale with k/M/G/T suffixes.
+export function formatDifficulty(d: number): string {
+ if (!d || d <= 0 || !isFinite(d)) return "0";
+ const units = ["", "k", "M", "G", "T", "P"];
+ let i = 0;
+ let v = d;
+ while (v >= 1000 && i < units.length - 1) {
+ v /= 1000;
+ i++;
+ }
+ const digits = v >= 100 ? 1 : 2;
+ return `${v.toFixed(digits)}${units[i]}`;
+}
+
+export function formatUptime(seconds: number): string {
+ if (!seconds || seconds < 0) return "—";
+ const d = Math.floor(seconds / 86400);
+ const h = Math.floor((seconds % 86400) / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (d > 0) return `${d}d ${h}h`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
+
+export function formatAgo(unixSeconds: number): string {
+ if (!unixSeconds) return "never";
+ const ageSec = Math.max(0, Date.now() / 1000 - unixSeconds);
+ if (ageSec < 60) return `${Math.floor(ageSec)}s ago`;
+ if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`;
+ if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h ago`;
+ return `${Math.floor(ageSec / 86400)}d ago`;
+}
+
+// Detect common Bitcoin mining hardware from the stratum useragent
+// string. This is a best-effort heuristic; unknown agents fall back
+// to a stripped version of the raw string. Order matters — check
+// more specific tokens before generic ones.
+export function detectHardware(ua: string): string {
+ if (!ua) return "unknown";
+ const s = ua.toLowerCase();
+ const rules: Array<[RegExp, string]> = [
+ [/bitaxe/, "Bitaxe"],
+ [/nerdqaxe/, "NerdQAxe+"],
+ [/nerdaxe/, "NerdAxe"],
+ [/nerdminer/, "NerdMiner"],
+ [/nerdoctaxe/, "NerdOctaxe"],
+ [/mccm/, "MCCM"],
+ [/lucky(?:miner)?/, "Lucky Miner"],
+ [/qaxe/, "QAxe"],
+ [/antminer/, "Antminer"],
+ [/whatsminer/, "Whatsminer"],
+ [/avalon/, "Avalon"],
+ [/cgminer/, "cgminer"],
+ [/bfgminer/, "bfgminer"],
+ [/bmminer/, "BMMiner"],
+ [/ckminer/, "ckminer"],
+ [/braiins/, "Braiins OS"],
+ [/micro[- ]?bt/, "MicroBT"],
+ [/esp32/, "ESP32"],
+ [/s9/, "Antminer S9"],
+ ];
+ for (const [re, label] of rules) {
+ if (re.test(s)) return label;
+ }
+ // Fall back to the first whitespace-free token, truncated.
+ const token = ua.split(/\s+/)[0] ?? ua;
+ return token.length > 24 ? token.slice(0, 24) + "…" : token;
+}
+
+// Rough expected time to find a block, in seconds, given pool and
+// network hashrate. Returns Infinity (displayed as "∞") if the pool
+// isn't mining.
+export function expectedBlockSeconds(
+ poolHs: number,
+ networkHs: number,
+ networkDifficulty: number,
+): number {
+ if (!poolHs || poolHs <= 0) return Infinity;
+ // Expected hashes per block = difficulty * 2^32.
+ const hashesPerBlock = networkDifficulty * 2 ** 32;
+ return hashesPerBlock / poolHs;
+}
+
+export function formatDuration(seconds: number): string {
+ if (!isFinite(seconds)) return "∞";
+ if (seconds < 60) return `${Math.round(seconds)}s`;
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
+ if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
+ if (seconds < 31536000) return `${(seconds / 86400).toFixed(1)}d`;
+ return `${(seconds / 31536000).toFixed(1)}y`;
+}
diff --git a/ui/src/global.d.ts b/ui/src/global.d.ts
new file mode 100644
index 0000000..4078e74
--- /dev/null
+++ b/ui/src/global.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/ui/src/lib/BestShares.svelte b/ui/src/lib/BestShares.svelte
new file mode 100644
index 0000000..5f8ca63
--- /dev/null
+++ b/ui/src/lib/BestShares.svelte
@@ -0,0 +1,59 @@
+
+
+
+ Best shares leaderboard
+ {#if rows.length === 0}
+ No shares submitted yet.
+ {:else}
+
+
+
+ | # |
+ Worker |
+ Best (round) |
+ Best (ever) |
+
+
+
+ {#each rows as r, i (r.worker)}
+
+ | {i + 1} |
+ {r.worker} |
+ {formatDifficulty(r.bestRound)} |
+ {formatDifficulty(r.bestEver)} |
+
+ {/each}
+
+
+ {/if}
+
+
+
diff --git a/ui/src/lib/BlocksTable.svelte b/ui/src/lib/BlocksTable.svelte
new file mode 100644
index 0000000..600bb1e
--- /dev/null
+++ b/ui/src/lib/BlocksTable.svelte
@@ -0,0 +1,56 @@
+
+
+
+ Blocks found
+ {#if blocks.length === 0}
+
+ No blocks found yet. Any solve will appear here instantly via the
+ log tailer, and persist to SQLite once Phase 2b.5 ships.
+
+ {:else}
+
+
+
+ | Height |
+ Hash |
+ When |
+ Source |
+
+
+
+ {#each blocks as b (b.height + "-" + b.found_at)}
+
+ | {b.height} |
+ {b.hash ? b.hash.slice(0, 16) + "…" : "—"} |
+ {formatAgo(new Date(b.found_at).getTime() / 1000)} |
+ {b.source} |
+
+ {/each}
+
+
+ {/if}
+
+
+
diff --git a/ui/src/lib/Header.svelte b/ui/src/lib/Header.svelte
new file mode 100644
index 0000000..7d2c772
--- /dev/null
+++ b/ui/src/lib/Header.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+
diff --git a/ui/src/lib/MinersTable.svelte b/ui/src/lib/MinersTable.svelte
new file mode 100644
index 0000000..c4441d9
--- /dev/null
+++ b/ui/src/lib/MinersTable.svelte
@@ -0,0 +1,144 @@
+
+
+
+ Miners
+ {#if rows.length === 0}
+ No miners connected.
+ {:else}
+
+
+
+
+ | Worker |
+ Hardware |
+ Hashrate (1m) |
+ Hashrate (1h) |
+ Diff |
+ Best (round) |
+ Best (ever) |
+ Last share |
+
+
+
+ {#each rows as r (r.workerName)}
+
+ |
+ {r.workerName}
+ {r.address}
+ |
+ {r.hardware} |
+ {formatHashrate(r.hashrate1m)} |
+ {formatHashrate(r.hashrate1h)} |
+ {formatDifficulty(r.difficulty)} |
+ {formatDifficulty(r.bestDiff)} |
+ {formatDifficulty(r.bestEver)} |
+ {formatAgo(r.lastShare)} |
+
+ {/each}
+
+
+
+ {/if}
+
+
+
diff --git a/ui/src/lib/PoolOverview.svelte b/ui/src/lib/PoolOverview.svelte
new file mode 100644
index 0000000..77cbe89
--- /dev/null
+++ b/ui/src/lib/PoolOverview.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+
Hashrate (1h)
+
{formatHashrate(data.hashrate_hs_1h)}
+
+ 1m {formatHashrate(data.hashrate_hs_1m)} · 5m {formatHashrate(data.hashrate_hs_5m)}
+ · 24h {formatHashrate(data.hashrate_hs_24h)}
+
+
+
+
+
Miners
+
{miners}
+
{workerCount} workers · {data.users?.length ?? 0} users
+
+
+
+
Network
+
{formatHashrate(data.network_hashrate_hs)}
+
+ diff {data.chain?.difficulty.toExponential(2) ?? "—"} ·
+ share {(poolShare * 1e9).toFixed(2)} ppb
+
+
+
+
+
Expected block
+
{formatDuration(expected)}
+
uptime {formatUptime(data.uptime_seconds)}
+
+
diff --git a/ui/src/main.ts b/ui/src/main.ts
new file mode 100644
index 0000000..745f7f6
--- /dev/null
+++ b/ui/src/main.ts
@@ -0,0 +1,6 @@
+import { mount } from "svelte";
+import App from "./App.svelte";
+import "./app.css";
+
+const app = mount(App, { target: document.getElementById("app")! });
+export default app;
diff --git a/ui/src/stores/snapshot.svelte.ts b/ui/src/stores/snapshot.svelte.ts
new file mode 100644
index 0000000..3ecc0e2
--- /dev/null
+++ b/ui/src/stores/snapshot.svelte.ts
@@ -0,0 +1,75 @@
+// Global snapshot store. Holds the latest Snapshot from kamado-api.
+// Uses Svelte 5 runes ($state) so any component that imports `snap`
+// re-renders automatically.
+//
+// Lifecycle:
+// - connect() fetches the initial snapshot via REST so the UI has
+// data to render before the socket is open.
+// - then opens /api/ws and overwrites the state on every frame.
+// - on close, backs off and reconnects. No jitter needed for now.
+
+import type { Snapshot } from "../types";
+
+type Status = "connecting" | "open" | "closed";
+
+export const snap = $state<{
+ data: Snapshot | null;
+ status: Status;
+ error: string | null;
+}>({
+ data: null,
+ status: "connecting",
+ error: null,
+});
+
+let socket: WebSocket | null = null;
+let retryMs = 1000;
+
+export async function connect(): Promise {
+ // Initial REST fetch — populates the first paint and tells us if
+ // the API is reachable at all before we commit to a WebSocket.
+ try {
+ const res = await fetch("/api/snapshot", { cache: "no-store" });
+ if (res.ok) {
+ snap.data = (await res.json()) as Snapshot;
+ snap.error = null;
+ } else {
+ snap.error = `snapshot HTTP ${res.status}`;
+ }
+ } catch (err) {
+ snap.error = `snapshot fetch failed: ${err}`;
+ }
+ openSocket();
+}
+
+function openSocket(): void {
+ snap.status = "connecting";
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const url = `${proto}//${location.host}/api/ws`;
+ socket = new WebSocket(url);
+
+ socket.addEventListener("open", () => {
+ snap.status = "open";
+ snap.error = null;
+ retryMs = 1000;
+ });
+
+ socket.addEventListener("message", (ev) => {
+ try {
+ snap.data = JSON.parse(ev.data as string) as Snapshot;
+ } catch (err) {
+ console.error("snapshot parse failed", err);
+ }
+ });
+
+ socket.addEventListener("close", () => {
+ snap.status = "closed";
+ socket = null;
+ setTimeout(openSocket, retryMs);
+ retryMs = Math.min(retryMs * 2, 15000);
+ });
+
+ socket.addEventListener("error", () => {
+ snap.error = "websocket error";
+ });
+}
diff --git a/ui/src/types.ts b/ui/src/types.ts
new file mode 100644
index 0000000..51df75c
--- /dev/null
+++ b/ui/src/types.ts
@@ -0,0 +1,107 @@
+// Mirrors api/internal/state.Snapshot. Keep field names aligned; the
+// Go side is authoritative.
+
+export type PoolStats = {
+ start: number;
+ update: number;
+ workers: number;
+ users: number;
+ disconnected: number;
+ shares: number;
+ accepted: number;
+ rejected: number;
+ dsps1: number;
+ dsps5: number;
+ dsps15: number;
+ dsps60: number;
+ dsps360: number;
+ dsps1440: number;
+ dsps10080: number;
+};
+
+export type User = {
+ user: string;
+ id: number;
+ workers: number;
+ bestdiff: number;
+ bestever: number;
+ dsps1: number;
+ dsps5: number;
+ dsps60: number;
+ dsps1440: number;
+ dsps10080: number;
+ lastshare: number;
+};
+
+export type Worker = {
+ user: string;
+ worker: string;
+ id: number;
+ dsps1: number;
+ dsps5: number;
+ dsps60: number;
+ dsps1440: number;
+ lastshare: number;
+ bestdiff: number;
+ bestever: number;
+ mindiff: number;
+ idle: boolean;
+};
+
+export type StratumClient = {
+ id: number;
+ enonce1: string;
+ diff: number;
+ dsps1: number;
+ dsps5: number;
+ dsps60: number;
+ dsps1440: number;
+ lastshare: number;
+ starttime: number;
+ address: string;
+ subscribed: boolean;
+ authorised: boolean;
+ idle: boolean;
+ useragent: string;
+ workername: string;
+ userid: number;
+ bestdiff: number;
+};
+
+export type BlockchainInfo = {
+ chain: string;
+ blocks: number;
+ headers: number;
+ bestblockhash: string;
+ difficulty: number;
+ mediantime: number;
+ verificationprogress: number;
+ initialblockdownload: boolean;
+};
+
+export type BlockRecord = {
+ height: number;
+ hash?: string;
+ reward_btc?: number;
+ found_at: string; // RFC 3339
+ source: string;
+};
+
+export type Snapshot = {
+ generated_at: string;
+ pool: PoolStats | null;
+ uptime_seconds: number;
+ users: User[] | null;
+ workers: Worker[] | null;
+ clients: StratumClient[] | null;
+ hashrate_hs_1m: number;
+ hashrate_hs_5m: number;
+ hashrate_hs_1h: number;
+ hashrate_hs_24h: number;
+ chain: BlockchainInfo | null;
+ network_hashrate_hs: number;
+ recent_blocks?: BlockRecord[];
+ ckpool_ok: boolean;
+ bitcoin_ok: boolean;
+ last_error?: string;
+};
diff --git a/ui/svelte.config.js b/ui/svelte.config.js
new file mode 100644
index 0000000..d0e6448
--- /dev/null
+++ b/ui/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+export default {
+ preprocess: vitePreprocess(),
+};
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
new file mode 100644
index 0000000..a400c5c
--- /dev/null
+++ b/ui/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "@tsconfig/svelte/tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
+}
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
new file mode 100644
index 0000000..456567e
--- /dev/null
+++ b/ui/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+
+// Dev server proxies /api (REST and WebSocket) to the running
+// kamado-api container on localhost:8080. In production the Go
+// server serves the built static files alongside /api itself, so
+// there is nothing to proxy.
+export default defineConfig({
+ plugins: [svelte()],
+ server: {
+ port: 5173,
+ proxy: {
+ "/api": {
+ target: "http://localhost:8080",
+ ws: true,
+ changeOrigin: false,
+ },
+ },
+ },
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ target: "es2022",
+ },
+});