mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Cut over Fabro web app to a server-backed SPA
Replace the old React Router SSR setup with a static SPA build served by fabro-server, move setup and GitHub auth handling into Rust, and update the default local web URL and stale Arc-era references to match the Fabro name.
This commit is contained in:
parent
e849441833
commit
eb91f2bcab
787 changed files with 9150 additions and 1395 deletions
182
Cargo.lock
generated
182
Cargo.lock
generated
|
|
@ -17,6 +17,41 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
|
|
@ -386,6 +421,29 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde_core",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.5.0"
|
||||
|
|
@ -618,6 +676,16 @@ dependencies = [
|
|||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.60"
|
||||
|
|
@ -748,6 +816,24 @@ dependencies = [
|
|||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coolor"
|
||||
version = "1.1.0"
|
||||
|
|
@ -942,9 +1028,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.14.4"
|
||||
|
|
@ -1819,10 +1915,12 @@ version = "0.176.2"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"fabro-agent",
|
||||
"fabro-api-types",
|
||||
|
|
@ -1847,9 +1945,12 @@ dependencies = [
|
|||
"hyper",
|
||||
"hyper-util",
|
||||
"jsonwebtoken",
|
||||
"mime_guess",
|
||||
"object_store",
|
||||
"openapiv3",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"rust-embed",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
|
|
@ -2513,6 +2614,16 @@ dependencies = [
|
|||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.32.3"
|
||||
|
|
@ -3094,6 +3205,15 @@ version = "0.1.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "insta"
|
||||
version = "1.46.3"
|
||||
|
|
@ -4040,6 +4160,12 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
|
|
@ -4417,6 +4543,18 @@ version = "0.3.32"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
|
|
@ -4927,6 +5065,40 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn 2.0.117",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.41.0"
|
||||
|
|
@ -6677,6 +6849,16 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ license = "MIT"
|
|||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
axum = { version = "0.8" }
|
||||
axum-extra = { version = "0.10", features = ["cookie-private"] }
|
||||
cookie = { version = "0.18", features = ["percent-encode", "private", "signed", "key-expansion"] }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
|
|
@ -69,6 +71,7 @@ fork = "0.2"
|
|||
exec = "0.3"
|
||||
slatedb = "0.11.2"
|
||||
object_store = "0.12.5"
|
||||
rust-embed = "8"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
import { apiJson } from "./api-client";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("apiJson", () => {
|
||||
test("returns parsed JSON on 200", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({ id: 1, name: "test" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}))
|
||||
);
|
||||
|
||||
const result = await apiJson<{ id: number; name: string }>("/items/1");
|
||||
|
||||
expect(result).toEqual({ id: 1, name: "test" });
|
||||
});
|
||||
|
||||
test("throws Response with status 404 and null body on not found", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response("Not Found: /items/999", { status: 404 }))
|
||||
);
|
||||
|
||||
try {
|
||||
await apiJson("/items/999");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (thrown) {
|
||||
expect(thrown).toBeInstanceOf(Response);
|
||||
const res = thrown as Response;
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("throws Response with status 500 and null body, stripping sensitive details", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
"Internal error: database connection string is postgres://admin:secret@db.internal:5432/prod",
|
||||
{ status: 500 }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await apiJson("/items/1");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (thrown) {
|
||||
expect(thrown).toBeInstanceOf(Response);
|
||||
const res = thrown as Response;
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { importPKCS8, SignJWT } from "jose";
|
||||
import { getAppConfig } from "./lib/config.server";
|
||||
import { isDemoMode } from "./lib/demo-mode.server";
|
||||
import { getUser } from "./lib/session.server";
|
||||
|
||||
const FABRO_JWT_PRIVATE_KEY = process.env.FABRO_JWT_PRIVATE_KEY;
|
||||
|
||||
function decodePemEnv(value: string): string {
|
||||
if (value.startsWith("-----")) return value;
|
||||
return Buffer.from(value, "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
let cachedKey: CryptoKey | null = null;
|
||||
|
||||
async function getSigningKey(): Promise<CryptoKey> {
|
||||
if (cachedKey) return cachedKey;
|
||||
if (!FABRO_JWT_PRIVATE_KEY) {
|
||||
throw new Error("FABRO_JWT_PRIVATE_KEY environment variable is not set");
|
||||
}
|
||||
cachedKey = await importPKCS8(decodePemEnv(FABRO_JWT_PRIVATE_KEY), "EdDSA");
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
async function signToken(sub?: string): Promise<string> {
|
||||
const key = await getSigningKey();
|
||||
return new SignJWT({ iss: "fabro-web", ...(sub ? { sub } : {}) })
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("30s")
|
||||
.sign(key);
|
||||
}
|
||||
|
||||
interface ApiOptions {
|
||||
init?: RequestInit;
|
||||
request?: Request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper that signs requests with a JWT for service-to-service auth.
|
||||
* When a request is provided, the authenticated user's URL is included as
|
||||
* the JWT `sub` claim.
|
||||
*/
|
||||
export async function apiFetch(
|
||||
path: string,
|
||||
options?: ApiOptions
|
||||
): Promise<Response> {
|
||||
const { base_url } = getAppConfig().api;
|
||||
const { init, request } = options ?? {};
|
||||
|
||||
let sub: string | undefined;
|
||||
if (request) {
|
||||
const user = await getUser(request);
|
||||
sub = user?.userUrl;
|
||||
}
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
if (FABRO_JWT_PRIVATE_KEY) {
|
||||
const token = await signToken(sub);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (request && isDemoMode(request)) {
|
||||
headers.set("X-Fabro-Demo", "1");
|
||||
}
|
||||
|
||||
const url = `${base_url}${path}`;
|
||||
try {
|
||||
return await fetch(url, { ...init, headers });
|
||||
} catch (cause) {
|
||||
throw new Error(`API request to ${url} failed`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed JSON fetch helper. Calls apiFetch and parses the JSON response.
|
||||
*/
|
||||
export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T> {
|
||||
const res = await apiFetch(path, options);
|
||||
if (!res.ok) throw new Response(null, { status: res.status });
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
58
apps/fabro-web/app/api.ts
Normal file
58
apps/fabro-web/app/api.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface ApiOptions {
|
||||
init?: RequestInit;
|
||||
request?: Request;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, options?: ApiOptions): Promise<Response> {
|
||||
const { init } = options ?? {};
|
||||
const response = await fetch(`/api/v1${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: init?.headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/auth/login";
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T> {
|
||||
const response = await apiFetch(path, options);
|
||||
if (!response.ok) {
|
||||
throw new Response(null, { status: response.status, statusText: response.statusText });
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function getSetupStatus(): Promise<{ configured: boolean }> {
|
||||
const response = await fetch("/api/v1/setup/status", { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Response(null, { status: response.status, statusText: response.statusText });
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getAuthMe(): Promise<{
|
||||
user: {
|
||||
login: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
userUrl: string;
|
||||
};
|
||||
provider: string;
|
||||
demoMode: boolean;
|
||||
features: { session_sandboxes: boolean; retros: boolean };
|
||||
}> {
|
||||
const response = await fetch("/api/v1/auth/me", { credentials: "include" });
|
||||
if (response.status === 401) {
|
||||
throw new Response(null, { status: 401, statusText: "Unauthorized" });
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Response(null, { status: response.status, statusText: response.statusText });
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
17
apps/fabro-web/app/entry.tsx
Normal file
17
apps/fabro-web/app/entry.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { routes } from "./router";
|
||||
|
||||
const router = createBrowserRouter(routes);
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Missing #root element");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
@ -12,55 +12,19 @@ import {
|
|||
BeakerIcon,
|
||||
ChartBarIcon,
|
||||
CheckBadgeIcon,
|
||||
Cog6ToothIcon,
|
||||
LightBulbIcon,
|
||||
MoonIcon,
|
||||
PlayIcon,
|
||||
RectangleStackIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Form, Link, Outlet, redirect, useLocation, useMatches } from "react-router";
|
||||
import { Form, Link, Outlet, redirect, useLocation, useMatches, useRevalidator } from "react-router";
|
||||
import { getAuthMe } from "../api";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { isDemoMode, demoCookieHeader } from "../lib/demo-mode.server";
|
||||
import { isGitHubAppConfigured } from "../lib/github.server";
|
||||
import { requireUser } from "../lib/session.server";
|
||||
import type { Route } from "./+types/app-shell";
|
||||
|
||||
const DEMO_USER = {
|
||||
userUrl: "",
|
||||
login: "demo",
|
||||
name: "Demo User",
|
||||
email: "demo@example.com",
|
||||
avatarUrl: "https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png",
|
||||
};
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const config = getAppConfig();
|
||||
const { provider } = config.web.auth;
|
||||
const demoMode = isDemoMode(request);
|
||||
if (provider === "insecure_disabled") {
|
||||
return { user: DEMO_USER, demoMode, features: config.features };
|
||||
}
|
||||
if (provider === "github" && !isGitHubAppConfigured()) {
|
||||
throw redirect("/setup");
|
||||
}
|
||||
const user = await requireUser(request);
|
||||
return { user, provider, demoMode, features: config.features };
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const form = await request.formData();
|
||||
if (form.get("intent") === "toggle-demo") {
|
||||
const enabled = !isDemoMode(request);
|
||||
const referer = request.headers.get("Referer") ?? "/start";
|
||||
const url = new URL(referer);
|
||||
return redirect(url.pathname + url.search, {
|
||||
headers: { "Set-Cookie": demoCookieHeader(enabled) },
|
||||
});
|
||||
}
|
||||
export async function loader() {
|
||||
return getAuthMe();
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
|
|
@ -75,10 +39,11 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
|||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function AppShell({ loaderData }: Route.ComponentProps) {
|
||||
export default function AppShell({ loaderData }: any) {
|
||||
const { user, provider, demoMode } = loaderData;
|
||||
const { pathname } = useLocation();
|
||||
const matches = useMatches();
|
||||
const revalidator = useRevalidator();
|
||||
const { theme, toggle } = useTheme();
|
||||
const currentNav = navigation.find((item) => pathname.startsWith(item.href));
|
||||
const title = currentNav?.name ?? "";
|
||||
|
|
@ -91,6 +56,16 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
const ThemeIcon = theme === "dark" ? SunIcon : MoonIcon;
|
||||
|
||||
async function toggleDemoMode() {
|
||||
await fetch("/api/v1/demo/toggle", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !demoMode }),
|
||||
});
|
||||
revalidator.revalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<Disclosure as="nav" className="bg-panel/50">
|
||||
|
|
@ -128,20 +103,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-4 flex items-center gap-3 md:ml-6">
|
||||
<Form method="POST" className="flex">
|
||||
<input type="hidden" name="intent" value="toggle-demo" />
|
||||
<button
|
||||
type="submit"
|
||||
className={classNames(
|
||||
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
|
||||
demoMode ? "text-teal-500" : "text-fg-muted",
|
||||
)}
|
||||
title={demoMode ? "Switch to live data" : "Switch to demo data"}
|
||||
>
|
||||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
</Form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDemoMode}
|
||||
className={classNames(
|
||||
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
|
||||
demoMode ? "text-teal-500" : "text-fg-muted",
|
||||
)}
|
||||
title={demoMode ? "Switch to live data" : "Switch to demo data"}
|
||||
>
|
||||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
|
|
@ -151,35 +124,33 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
<ThemeIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
{provider !== "tailscale" && (
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="relative flex max-w-xs items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
alt=""
|
||||
src={user.avatarUrl}
|
||||
className="size-8 rounded-full outline -outline-offset-1 outline-line-strong"
|
||||
/>
|
||||
</MenuButton>
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="relative flex max-w-xs items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
alt=""
|
||||
src={user.avatarUrl}
|
||||
className="size-8 rounded-full outline -outline-offset-1 outline-line-strong"
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<Form method="POST" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</Form>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
)}
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<Form method="POST" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</Form>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mr-2 flex md:hidden">
|
||||
|
|
@ -240,20 +211,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Form method="POST" className="flex">
|
||||
<input type="hidden" name="intent" value="toggle-demo" />
|
||||
<button
|
||||
type="submit"
|
||||
className={classNames(
|
||||
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
|
||||
demoMode ? "text-teal-500" : "text-fg-muted",
|
||||
)}
|
||||
title={demoMode ? "Switch to live data" : "Switch to demo data"}
|
||||
>
|
||||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
</Form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDemoMode}
|
||||
className={classNames(
|
||||
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
|
||||
demoMode ? "text-teal-500" : "text-fg-muted",
|
||||
)}
|
||||
title={demoMode ? "Switch to live data" : "Switch to demo data"}
|
||||
>
|
||||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "smol-toml";
|
||||
|
||||
interface AuthConfig {
|
||||
provider: "github" | "tailscale" | "insecure_disabled";
|
||||
allowed_usernames: string[];
|
||||
}
|
||||
|
||||
interface ApiConfig {
|
||||
base_url: string;
|
||||
authentication_strategy: "jwt" | "insecure_disabled";
|
||||
}
|
||||
|
||||
interface GitConfig {
|
||||
provider: "github";
|
||||
app_id: string | null;
|
||||
client_id: string | null;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
interface Features {
|
||||
session_sandboxes: boolean;
|
||||
retros: boolean;
|
||||
}
|
||||
|
||||
interface WebConfig {
|
||||
url: string;
|
||||
auth: AuthConfig;
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
web: WebConfig;
|
||||
api: ApiConfig;
|
||||
git: GitConfig;
|
||||
features: Features;
|
||||
}
|
||||
|
||||
const AUTH_DEFAULTS: AuthConfig = {
|
||||
provider: "github",
|
||||
allowed_usernames: [],
|
||||
};
|
||||
|
||||
const WEB_DEFAULTS: WebConfig = {
|
||||
url: "http://localhost:5173",
|
||||
auth: AUTH_DEFAULTS,
|
||||
};
|
||||
|
||||
const API_DEFAULTS: ApiConfig = {
|
||||
base_url: "http://localhost:3000/api/v1",
|
||||
authentication_strategy: "jwt",
|
||||
};
|
||||
|
||||
const GIT_DEFAULTS: GitConfig = {
|
||||
provider: "github",
|
||||
app_id: null,
|
||||
client_id: null,
|
||||
slug: null,
|
||||
};
|
||||
|
||||
const FEATURES_DEFAULTS: Features = {
|
||||
session_sandboxes: false,
|
||||
retros: false,
|
||||
};
|
||||
|
||||
export const FABRO_CONFIG_PATH = join(homedir(), ".fabro", "server.toml");
|
||||
|
||||
function loadAppConfig(): AppConfig {
|
||||
const configPath = FABRO_CONFIG_PATH;
|
||||
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
raw = parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
// File doesn't exist or is unreadable — use defaults
|
||||
}
|
||||
|
||||
const rawWeb = (raw.web ?? {}) as Record<string, unknown>;
|
||||
const rawWebAuth = (rawWeb.auth ?? {}) as Partial<AuthConfig>;
|
||||
const rawApi = (raw.api ?? {}) as Partial<ApiConfig>;
|
||||
const rawGit = (raw.git ?? {}) as Partial<GitConfig>;
|
||||
const rawFeatures = (raw.features ?? {}) as Partial<Features>;
|
||||
|
||||
const demo = process.env.FABRO_DEMO === "1";
|
||||
|
||||
return {
|
||||
web: {
|
||||
...WEB_DEFAULTS,
|
||||
url: (rawWeb.url as string) ?? WEB_DEFAULTS.url,
|
||||
auth: demo
|
||||
? { provider: "insecure_disabled", allowed_usernames: [] }
|
||||
: { ...AUTH_DEFAULTS, ...rawWebAuth },
|
||||
},
|
||||
api: demo
|
||||
? { ...API_DEFAULTS, ...rawApi, authentication_strategy: "insecure_disabled" }
|
||||
: { ...API_DEFAULTS, ...rawApi },
|
||||
git: { ...GIT_DEFAULTS, ...rawGit },
|
||||
features: { ...FEATURES_DEFAULTS, ...rawFeatures },
|
||||
};
|
||||
}
|
||||
|
||||
/** Loaded once at module init; call reloadAppConfig() to pick up changes. */
|
||||
let appConfig: AppConfig = loadAppConfig();
|
||||
|
||||
export function getAppConfig(): AppConfig {
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
export function reloadAppConfig(): void {
|
||||
appConfig = loadAppConfig();
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDatabase(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const fabroDir = path.join(os.homedir(), ".fabro");
|
||||
fs.mkdirSync(fabroDir, { recursive: true });
|
||||
|
||||
db = new Database(path.join(fabroDir, "fabro-web.db"));
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_url TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_user_url ON web_sessions (user_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at);
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
const COOKIE_NAME = "fabro-demo";
|
||||
|
||||
/** Check whether demo mode is active for this request (cookie, then env var fallback). */
|
||||
export function isDemoMode(request: Request): boolean {
|
||||
const cookies = request.headers.get("Cookie") ?? "";
|
||||
const match = cookies.match(/(?:^|;\s*)fabro-demo=([^;]*)/);
|
||||
if (match) return match[1] === "1";
|
||||
return process.env.FABRO_DEMO === "1";
|
||||
}
|
||||
|
||||
/** Build a Set-Cookie header value to persist the demo mode preference. */
|
||||
export function demoCookieHeader(enabled: boolean): string {
|
||||
const value = enabled ? "1" : "0";
|
||||
return `${COOKIE_NAME}=${value}; Path=/; SameSite=Lax; Max-Age=${60 * 60 * 24 * 365}`;
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { GitHub, generateState } from "arctic";
|
||||
import { getAppConfig } from "./config.server";
|
||||
|
||||
export { generateState };
|
||||
|
||||
export function getGitHubOAuth() {
|
||||
const clientId = getAppConfig().git.client_id;
|
||||
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error("GitHub App is not configured");
|
||||
}
|
||||
return new GitHub(clientId, clientSecret, null);
|
||||
}
|
||||
|
||||
export function isGitHubAppConfigured(): boolean {
|
||||
return getAppConfig().git.client_id !== null;
|
||||
}
|
||||
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { mergeEnv } from "./merge-env";
|
||||
|
||||
describe("mergeEnv", () => {
|
||||
test("replaces existing key with new value", () => {
|
||||
const result = mergeEnv(
|
||||
"FOO=old\nBAR=keep\n",
|
||||
new Map([["FOO", "new"]]),
|
||||
);
|
||||
expect(result).toContain("FOO=new");
|
||||
expect(result).toContain("BAR=keep");
|
||||
});
|
||||
|
||||
test("preserves comments, blank lines, and unrelated vars", () => {
|
||||
const existing = "# A comment\n\nFOO=old\n# Another\nBAR=keep\n";
|
||||
const result = mergeEnv(existing, new Map([["FOO", "new"]]));
|
||||
expect(result).toContain("# A comment");
|
||||
expect(result).toContain("# Another");
|
||||
expect(result).toContain("FOO=new");
|
||||
expect(result).toContain("BAR=keep");
|
||||
});
|
||||
|
||||
test("appends keys not already present", () => {
|
||||
const result = mergeEnv(
|
||||
"FOO=old\n",
|
||||
new Map([
|
||||
["FOO", "new"],
|
||||
["BAZ", "added"],
|
||||
]),
|
||||
);
|
||||
expect(result).toContain("FOO=new");
|
||||
expect(result).toContain("BAZ=added");
|
||||
});
|
||||
|
||||
test("handles export prefix", () => {
|
||||
const result = mergeEnv(
|
||||
"export FOO=old\nexport BAR=keep\n",
|
||||
new Map([["FOO", "new"]]),
|
||||
);
|
||||
expect(result).toContain("export FOO=new");
|
||||
expect(result).toContain("export BAR=keep");
|
||||
});
|
||||
|
||||
test("idempotent: merging twice produces same result", () => {
|
||||
const vars = new Map([
|
||||
["FOO", "new"],
|
||||
["BAZ", "added"],
|
||||
]);
|
||||
const first = mergeEnv("FOO=old\nBAR=keep\n", vars);
|
||||
const second = mergeEnv(first, vars);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
test("full scenario matches expected output", () => {
|
||||
const result = mergeEnv(
|
||||
"FOO=old\nBAR=keep",
|
||||
new Map([
|
||||
["FOO", "new"],
|
||||
["BAZ", "added"],
|
||||
]),
|
||||
);
|
||||
expect(result).toBe("FOO=new\nBAR=keep\nBAZ=added\n");
|
||||
});
|
||||
|
||||
test("empty existing string", () => {
|
||||
const result = mergeEnv(
|
||||
"",
|
||||
new Map([
|
||||
["FOO", "bar"],
|
||||
["BAZ", "qux"],
|
||||
]),
|
||||
);
|
||||
expect(result).toContain("FOO=bar");
|
||||
expect(result).toContain("BAZ=qux");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/**
|
||||
* Merge new key=value pairs into an existing .env file string.
|
||||
* - Replaces lines whose key matches (handles optional `export ` prefix)
|
||||
* - Preserves comments, blank lines, and unrelated variables
|
||||
* - Appends keys not already present
|
||||
*/
|
||||
export function mergeEnv(
|
||||
existing: string,
|
||||
newVars: Map<string, string>,
|
||||
): string {
|
||||
const handledKeys = new Set<string>();
|
||||
const resultLines: string[] = [];
|
||||
|
||||
for (const line of existing.split("\n")) {
|
||||
const eqPos = line.indexOf("=");
|
||||
if (eqPos !== -1) {
|
||||
let rawKey = line.slice(0, eqPos).trim();
|
||||
const hasExport = rawKey.startsWith("export ");
|
||||
if (hasExport) {
|
||||
rawKey = rawKey.slice("export ".length).trim();
|
||||
}
|
||||
if (rawKey.length > 0 && !rawKey.startsWith("#")) {
|
||||
const newVal = newVars.get(rawKey);
|
||||
if (newVal !== undefined) {
|
||||
const prefix = hasExport ? "export " : "";
|
||||
resultLines.push(`${prefix}${rawKey}=${newVal}`);
|
||||
handledKeys.add(rawKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
for (const [key, val] of newVars) {
|
||||
if (!handledKeys.has(key)) {
|
||||
resultLines.push(`${key}=${val}`);
|
||||
}
|
||||
}
|
||||
|
||||
let result = resultLines.join("\n");
|
||||
if (!result.endsWith("\n")) {
|
||||
result += "\n";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { createSessionStorage } from "react-router";
|
||||
import crypto from "node:crypto";
|
||||
import { getDatabase } from "./db.server";
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
user_url: string;
|
||||
data: string;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function cleanupExpiredSessions() {
|
||||
const db = getDatabase();
|
||||
db.prepare("DELETE FROM web_sessions WHERE expires_at IS NOT NULL AND expires_at < ?").run(
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export function createSqliteSessionStorage(secret: string) {
|
||||
return createSessionStorage({
|
||||
cookie: {
|
||||
name: "__fabro_session",
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secrets: [secret],
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
|
||||
},
|
||||
async createData(data, expiresAt) {
|
||||
const db = getDatabase();
|
||||
const id = crypto.randomUUID();
|
||||
const userUrl = (data.userUrl as string) ?? "";
|
||||
const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS;
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO web_sessions (id, user_url, data, expires_at) VALUES (?, ?, ?, ?)",
|
||||
).run(id, userUrl, JSON.stringify(data), expiresAtMs);
|
||||
|
||||
// Probabilistic cleanup (~1% of creates)
|
||||
if (Math.random() < 0.01) {
|
||||
cleanupExpiredSessions();
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
async readData(id) {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.prepare("SELECT * FROM web_sessions WHERE id = ?")
|
||||
.get(id) as SessionRow | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
if (row.expires_at && row.expires_at < Date.now()) {
|
||||
db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(row.data);
|
||||
},
|
||||
async updateData(id, data, expiresAt) {
|
||||
const db = getDatabase();
|
||||
const userUrl = (data.userUrl as string) ?? "";
|
||||
const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE web_sessions SET user_url = ?, data = ?, expires_at = ? WHERE id = ?",
|
||||
).run(userUrl, JSON.stringify(data), expiresAtMs, id);
|
||||
},
|
||||
async deleteData(id) {
|
||||
const db = getDatabase();
|
||||
db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// --- Mocks (must be set up before importing module under test) ---
|
||||
|
||||
let testAuthConfig = { provider: "github" as string, allowed_usernames: [] as string[] };
|
||||
|
||||
mock.module("./config.server", () => ({
|
||||
getAppConfig: () => ({ web: { auth: testAuthConfig } }),
|
||||
reloadAppConfig: () => {},
|
||||
FABRO_CONFIG_PATH: "/tmp/test.toml",
|
||||
}));
|
||||
|
||||
let sessionData: Record<string, unknown> = {};
|
||||
|
||||
mock.module("./session-storage.server", () => ({
|
||||
createSqliteSessionStorage: () => ({
|
||||
getSession: async () => ({
|
||||
get: (key: string) => sessionData[key],
|
||||
}),
|
||||
commitSession: async () => "",
|
||||
destroySession: async () => "",
|
||||
}),
|
||||
}));
|
||||
|
||||
process.env.SESSION_SECRET = "test-secret";
|
||||
|
||||
const { getUser } = await import("./session.server");
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe("getUser", () => {
|
||||
beforeEach(() => {
|
||||
sessionData = {};
|
||||
testAuthConfig = { provider: "github", allowed_usernames: [] };
|
||||
});
|
||||
|
||||
describe("tailscale provider", () => {
|
||||
beforeEach(() => {
|
||||
testAuthConfig = { provider: "tailscale", allowed_usernames: ["user@example.com"] };
|
||||
});
|
||||
|
||||
test("returns user from headers when login is in allowed_usernames", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: {
|
||||
"Tailscale-User-Login": "user@example.com",
|
||||
"Tailscale-User-Name": "Test User",
|
||||
"Tailscale-User-Profile-Pic": "https://example.com/pic.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toEqual({
|
||||
userUrl: "tailscale:user@example.com",
|
||||
login: "user@example.com",
|
||||
name: "Test User",
|
||||
email: "user@example.com",
|
||||
avatarUrl: "https://example.com/pic.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when Tailscale-User-Login header is missing", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when login is not in allowed_usernames", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: {
|
||||
"Tailscale-User-Login": "stranger@example.com",
|
||||
"Tailscale-User-Name": "Stranger",
|
||||
},
|
||||
});
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("github provider", () => {
|
||||
beforeEach(() => {
|
||||
testAuthConfig = { provider: "github", allowed_usernames: [] };
|
||||
});
|
||||
|
||||
test("returns user from session", async () => {
|
||||
sessionData = {
|
||||
userUrl: "https://github.com/octocat",
|
||||
login: "octocat",
|
||||
name: "Octocat",
|
||||
email: "octocat@github.com",
|
||||
avatarUrl: "https://github.com/octocat.png",
|
||||
};
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toEqual({
|
||||
userUrl: "https://github.com/octocat",
|
||||
login: "octocat",
|
||||
name: "Octocat",
|
||||
email: "octocat@github.com",
|
||||
avatarUrl: "https://github.com/octocat.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when session is empty", async () => {
|
||||
sessionData = {};
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getAppConfig } from "./config.server";
|
||||
import { createSqliteSessionStorage } from "./session-storage.server";
|
||||
|
||||
interface SessionData {
|
||||
userUrl: string;
|
||||
githubId: number;
|
||||
githubNodeId: string;
|
||||
login: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
function getSessionStorage() {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("SESSION_SECRET is not set");
|
||||
}
|
||||
return createSqliteSessionStorage(secret);
|
||||
}
|
||||
|
||||
export async function getSession(request: Request) {
|
||||
const storage = getSessionStorage();
|
||||
return storage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
const storage = getSessionStorage();
|
||||
return storage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
const storage = getSessionStorage();
|
||||
return storage.destroySession(session);
|
||||
}
|
||||
|
||||
export async function getUser(request: Request) {
|
||||
const { provider, allowed_usernames } = getAppConfig().web.auth;
|
||||
|
||||
if (provider === "tailscale") {
|
||||
const login = request.headers.get("Tailscale-User-Login");
|
||||
if (!login || !allowed_usernames.includes(login)) return null;
|
||||
return {
|
||||
userUrl: `tailscale:${login}`,
|
||||
login,
|
||||
name: request.headers.get("Tailscale-User-Name") ?? login,
|
||||
email: login,
|
||||
avatarUrl: request.headers.get("Tailscale-User-Profile-Pic") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const session = await getSession(request);
|
||||
const login = session.get("login");
|
||||
if (!login) return null;
|
||||
return {
|
||||
userUrl: session.get("userUrl") ?? "",
|
||||
login,
|
||||
name: session.get("name") ?? login,
|
||||
email: session.get("email") ?? "",
|
||||
avatarUrl: session.get("avatarUrl") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireUser(request: Request) {
|
||||
const user = await getUser(request);
|
||||
if (!user) throw redirect("/auth/login");
|
||||
return user;
|
||||
}
|
||||
|
|
@ -1,62 +1,23 @@
|
|||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { isRouteErrorResponse, Outlet } from "react-router";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./app.css";
|
||||
|
||||
const themeScript = `(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()`;
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
|
||||
{ rel: "icon", href: "/favicon.ico", sizes: "32x32" },
|
||||
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="h-full bg-atmosphere" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="h-full">
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
export default function Root() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ThemeBoot />
|
||||
<Outlet />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
function ThemeBoot() {
|
||||
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: any) {
|
||||
let status = 500;
|
||||
let heading = "Something went wrong";
|
||||
let message = "An unexpected error occurred. Please try again.";
|
||||
|
|
@ -72,16 +33,14 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|||
message = error.statusText || message;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
if (import.meta.env.DEV) {
|
||||
message = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
message = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
const is404 = status === 404;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-col items-center justify-center px-6 py-24">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center px-6 py-24">
|
||||
<div className="text-center">
|
||||
<p
|
||||
className="font-mono text-[8rem] font-bold leading-none tracking-tighter"
|
||||
|
|
|
|||
139
apps/fabro-web/app/router.tsx
Normal file
139
apps/fabro-web/app/router.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { createElement } from "react";
|
||||
import {
|
||||
type RouteObject,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useParams,
|
||||
} from "react-router";
|
||||
|
||||
import Root, { ErrorBoundary as RootErrorBoundary } from "./root";
|
||||
import * as RedirectHome from "./routes/redirect-home";
|
||||
import * as Setup from "./routes/setup";
|
||||
import * as SetupComplete from "./routes/setup-complete";
|
||||
import * as AuthLogin from "./routes/auth-login";
|
||||
import * as Start from "./routes/start";
|
||||
import * as SessionDetail from "./routes/session-detail";
|
||||
import * as Workflows from "./routes/workflows";
|
||||
import * as WorkflowDetail from "./routes/workflow-detail";
|
||||
import * as WorkflowDefinition from "./routes/workflow-definition";
|
||||
import * as WorkflowDiagram from "./routes/workflow-diagram";
|
||||
import * as WorkflowRuns from "./routes/workflow-runs";
|
||||
import * as Runs from "./routes/runs";
|
||||
import * as RunDetail from "./routes/run-detail";
|
||||
import * as RunOverview from "./routes/run-overview";
|
||||
import * as RunStages from "./routes/run-stages";
|
||||
import * as RunSettings from "./routes/run-settings";
|
||||
import * as RunGraph from "./routes/run-graph";
|
||||
import * as RunFiles from "./routes/run-files";
|
||||
import * as RunVerification from "./routes/run-verification";
|
||||
import * as RunUsage from "./routes/run-usage";
|
||||
import * as RunRetro from "./routes/run-retro";
|
||||
import * as VerificationCriteria from "./routes/verification-criteria";
|
||||
import * as VerificationCriterion from "./routes/verification-criterion";
|
||||
import * as VerificationControls from "./routes/verification-controls";
|
||||
import * as VerificationControl from "./routes/verification-control";
|
||||
import * as Retros from "./routes/retros";
|
||||
import * as Insights from "./routes/insights";
|
||||
import * as InsightsEditor from "./routes/insights-editor";
|
||||
import * as InsightsNew from "./routes/insights-new";
|
||||
import * as Settings from "./routes/settings";
|
||||
import AppShellModule from "./layouts/app-shell";
|
||||
import { loader as appShellLoader } from "./layouts/app-shell";
|
||||
|
||||
type RouteModule = {
|
||||
default: React.ComponentType<any>;
|
||||
loader?: RouteObject["loader"];
|
||||
action?: RouteObject["action"];
|
||||
ErrorBoundary?: React.ComponentType<any>;
|
||||
};
|
||||
|
||||
function withRouteModule(module: RouteModule) {
|
||||
return function WrappedRouteComponent() {
|
||||
const loaderData = useLoaderData();
|
||||
const actionData = useActionData();
|
||||
const params = useParams();
|
||||
return createElement(module.default, { loaderData, actionData, params });
|
||||
};
|
||||
}
|
||||
|
||||
function route(
|
||||
path: string,
|
||||
module: RouteModule,
|
||||
extra: Omit<RouteObject, "path" | "Component" | "loader" | "action" | "index"> = {},
|
||||
): RouteObject {
|
||||
return {
|
||||
path,
|
||||
loader: module.loader,
|
||||
action: module.action,
|
||||
Component: withRouteModule(module),
|
||||
ErrorBoundary: module.ErrorBoundary,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function indexRoute(module: RouteModule): RouteObject {
|
||||
return {
|
||||
index: true,
|
||||
loader: module.loader,
|
||||
action: module.action,
|
||||
Component: withRouteModule(module),
|
||||
ErrorBoundary: module.ErrorBoundary,
|
||||
};
|
||||
}
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
Component: Root,
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
indexRoute(RedirectHome),
|
||||
route("setup", Setup),
|
||||
route("setup/complete", SetupComplete),
|
||||
route("auth/login", AuthLogin),
|
||||
{
|
||||
loader: appShellLoader,
|
||||
Component: withRouteModule({
|
||||
default: AppShellModule,
|
||||
}),
|
||||
children: [
|
||||
route("start", Start),
|
||||
route("sessions/:sessionId", SessionDetail),
|
||||
route("workflows", Workflows),
|
||||
route("workflows/:name", WorkflowDetail, {
|
||||
children: [
|
||||
indexRoute(WorkflowDefinition),
|
||||
route("diagram", WorkflowDiagram),
|
||||
route("runs", WorkflowRuns),
|
||||
],
|
||||
}),
|
||||
route("runs", Runs),
|
||||
route("runs/:id", RunDetail, {
|
||||
children: [
|
||||
indexRoute(RunOverview),
|
||||
route("stages/:stageId", RunStages),
|
||||
route("settings", RunSettings),
|
||||
route("graph", RunGraph),
|
||||
route("files", RunFiles),
|
||||
route("verification", RunVerification),
|
||||
route("usage", RunUsage),
|
||||
route("retro", RunRetro),
|
||||
],
|
||||
}),
|
||||
route("verification/criteria", VerificationCriteria),
|
||||
route("verification/criteria/:id", VerificationCriterion),
|
||||
route("verification/controls", VerificationControls),
|
||||
route("verification/controls/:id", VerificationControl),
|
||||
route("retros", Retros),
|
||||
route("insights", Insights, {
|
||||
children: [
|
||||
indexRoute(InsightsEditor),
|
||||
route("new", InsightsNew),
|
||||
],
|
||||
}),
|
||||
route("settings", Settings),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import {
|
||||
type RouteConfig,
|
||||
index,
|
||||
layout,
|
||||
route,
|
||||
} from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
index("routes/redirect-home.tsx"),
|
||||
route("setup", "routes/setup.tsx"),
|
||||
route("setup/callback", "routes/setup-callback.tsx"),
|
||||
route("auth/login", "routes/auth-login.tsx"),
|
||||
route("auth/callback", "routes/auth-callback.tsx"),
|
||||
route("auth/logout", "routes/auth-logout.tsx"),
|
||||
layout("layouts/app-shell.tsx", [
|
||||
route("start", "routes/start.tsx"),
|
||||
route("sessions/:sessionId", "routes/session-detail.tsx"),
|
||||
route("workflows", "routes/workflows.tsx"),
|
||||
route("workflows/:name", "routes/workflow-detail.tsx", [
|
||||
index("routes/workflow-definition.tsx"),
|
||||
route("diagram", "routes/workflow-diagram.tsx"),
|
||||
route("runs", "routes/workflow-runs.tsx"),
|
||||
]),
|
||||
route("runs", "routes/runs.tsx"),
|
||||
route("runs/:id", "routes/run-detail.tsx", [
|
||||
index("routes/run-overview.tsx"),
|
||||
route("stages/:stageId", "routes/run-stages.tsx"),
|
||||
route("settings", "routes/run-settings.tsx"),
|
||||
route("graph", "routes/run-graph.tsx"),
|
||||
route("files", "routes/run-files.tsx"),
|
||||
route("verification", "routes/run-verification.tsx"),
|
||||
route("usage", "routes/run-usage.tsx"),
|
||||
route("retro", "routes/run-retro.tsx"),
|
||||
]),
|
||||
route("verification/criteria", "routes/verification-criteria.tsx"),
|
||||
route("verification/criteria/:id", "routes/verification-criterion.tsx"),
|
||||
route("verification/controls", "routes/verification-controls.tsx"),
|
||||
route("verification/controls/:id", "routes/verification-control.tsx"),
|
||||
route("retros", "routes/retros.tsx"),
|
||||
route("insights", "routes/insights.tsx", [
|
||||
index("routes/insights-editor.tsx"),
|
||||
route("new", "routes/insights-new.tsx"),
|
||||
]),
|
||||
route("settings", "routes/settings.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { getGitHubOAuth } from "../lib/github.server";
|
||||
import { getSession, commitSession } from "../lib/session.server";
|
||||
import type { Route } from "./+types/auth-callback";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
|
||||
const cookies = request.headers.get("Cookie") ?? "";
|
||||
const stateMatch = cookies.match(/fabro_oauth_state=([^;]+)/);
|
||||
const storedState = stateMatch?.[1];
|
||||
|
||||
if (!code || !state || state !== storedState) {
|
||||
throw redirect("/auth/login");
|
||||
}
|
||||
|
||||
const github = getGitHubOAuth();
|
||||
const tokens = await github.validateAuthorizationCode(code);
|
||||
const accessToken = tokens.accessToken();
|
||||
|
||||
const [userResponse, emailsResponse] = await Promise.all([
|
||||
fetch("https://api.github.com/user", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}),
|
||||
fetch("https://api.github.com/user/emails", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}),
|
||||
]);
|
||||
const profile = (await userResponse.json()) as {
|
||||
id: number;
|
||||
node_id: string;
|
||||
login: string;
|
||||
name: string | null;
|
||||
avatar_url: string;
|
||||
};
|
||||
const emails = (await emailsResponse.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? "";
|
||||
|
||||
const { allowed_usernames } = getAppConfig().web.auth;
|
||||
if (allowed_usernames.length > 0 && !allowed_usernames.includes(profile.login)) {
|
||||
throw redirect("/auth/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const session = await getSession(request);
|
||||
session.set("userUrl", `https://github.com/${profile.login}`);
|
||||
session.set("githubId", profile.id);
|
||||
session.set("githubNodeId", profile.node_id);
|
||||
session.set("login", profile.login);
|
||||
session.set("name", profile.name ?? profile.login);
|
||||
session.set("email", primaryEmail);
|
||||
session.set("avatarUrl", profile.avatar_url);
|
||||
session.set("accessToken", accessToken);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", "fabro_oauth_state=; HttpOnly; Path=/; Max-Age=0");
|
||||
|
||||
return redirect("/start", { headers });
|
||||
}
|
||||
|
|
@ -1,40 +1,5 @@
|
|||
import { redirect } from "react-router";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { getGitHubOAuth, generateState } from "../lib/github.server";
|
||||
import type { Route } from "./+types/auth-login";
|
||||
|
||||
export function loader() {
|
||||
const { provider } = getAppConfig().web.auth;
|
||||
return { provider };
|
||||
}
|
||||
|
||||
export function action({ request }: Route.ActionArgs) {
|
||||
const github = getGitHubOAuth();
|
||||
const state = generateState();
|
||||
const authUrl = github.createAuthorizationURL(state, ["read:user", "user:email"]);
|
||||
|
||||
return redirect(authUrl.toString(), {
|
||||
headers: {
|
||||
"Set-Cookie": `fabro_oauth_state=${state}; HttpOnly; Path=/; Max-Age=600; SameSite=Lax`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function AuthLogin({ loaderData }: Route.ComponentProps) {
|
||||
if (loaderData.provider === "tailscale") {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Access via Tailscale
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
This app is protected by Tailscale. Make sure you are connected to your Tailscale network and your account is authorized.
|
||||
</p>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthLogin() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
|
|
@ -43,15 +8,15 @@ export default function AuthLogin({ loaderData }: Route.ComponentProps) {
|
|||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
Authenticate with your GitHub account to continue.
|
||||
</p>
|
||||
<form method="POST" className="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
<div className="mt-6">
|
||||
<a
|
||||
href="/auth/login/github"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
>
|
||||
<GitHubMark />
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
</form>
|
||||
</a>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { getSession, destroySession } from "../lib/session.server";
|
||||
import type { Route } from "./+types/auth-logout";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const { provider } = getAppConfig().web.auth;
|
||||
if (provider === "tailscale") {
|
||||
return redirect("/start");
|
||||
}
|
||||
const session = await getSession(request);
|
||||
return redirect("/auth/login", {
|
||||
headers: { "Set-Cookie": await destroySession(session) },
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { Link, Outlet, useNavigate } from "react-router";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { timeAgo } from "../lib/time";
|
||||
import type { PaginatedSavedQueryList, PaginatedHistoryEntryList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/insights";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Insights — Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +28,7 @@ export interface HistoryEntry {
|
|||
rowsReturned: number;
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const [{ data: apiQueries }, { data: apiHistory }] = await Promise.all([
|
||||
apiJson<PaginatedSavedQueryList>("/insights/queries", { request }),
|
||||
apiJson<PaginatedHistoryEntryList>("/insights/history", { request }),
|
||||
|
|
@ -49,7 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
return { savedQueries, historyEntries };
|
||||
}
|
||||
|
||||
export default function InsightsLayout({ loaderData }: Route.ComponentProps) {
|
||||
export default function InsightsLayout({ loaderData }: any) {
|
||||
const { savedQueries, historyEntries } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { isGitHubAppConfigured } from "../lib/github.server";
|
||||
import { getUser } from "../lib/session.server";
|
||||
import type { Route } from "./+types/redirect-home";
|
||||
import { getAuthMe, getSetupStatus } from "../api";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { provider } = getAppConfig().web.auth;
|
||||
if (provider === "github" && !isGitHubAppConfigured()) {
|
||||
export async function loader() {
|
||||
const setup = await getSetupStatus();
|
||||
if (!setup.configured) {
|
||||
return redirect("/setup");
|
||||
}
|
||||
if (provider !== "insecure_disabled") {
|
||||
const user = await getUser(request);
|
||||
if (!user) {
|
||||
|
||||
try {
|
||||
await getAuthMe();
|
||||
} catch (error) {
|
||||
if (error instanceof Response && error.status === 401) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return redirect("/start");
|
||||
}
|
||||
|
||||
export default function RedirectHome() {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { useNavigate } from "react-router";
|
|||
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { smoothnessConfig, formatDurationMs } from "../data/retros";
|
||||
import type { SmoothnessRating } from "../data/retros";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRetroList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/retros";
|
||||
|
||||
interface RetroRow {
|
||||
run_id: string;
|
||||
|
|
@ -17,7 +16,7 @@ interface RetroRow {
|
|||
friction_point_count: number;
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const { data: apiRetros } = await apiJson<PaginatedRetroList>("/retros", { request });
|
||||
const retros: RetroRow[] = apiRetros.map((r) => ({
|
||||
run_id: r.run.id,
|
||||
|
|
@ -31,7 +30,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
return { retros };
|
||||
}
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Retros \u2014 Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +70,7 @@ function truncate(text: string, maxLength: number): string {
|
|||
return text.slice(0, maxLength) + "\u2026";
|
||||
}
|
||||
|
||||
export default function Retros({ loaderData }: Route.ComponentProps) {
|
||||
export default function Retros({ loaderData }: any) {
|
||||
const { retros } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
|||
import { Link, Outlet, useFetcher, useLocation } from "react-router";
|
||||
import { columnNames, mapRunListItem, statusColors } from "../data/runs";
|
||||
import type { ColumnStatus } from "../data/runs";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunList, PreviewUrlResponse } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-detail";
|
||||
|
||||
const tabs = [
|
||||
{ name: "Overview", path: "", count: null },
|
||||
|
|
@ -19,7 +18,7 @@ const tabs = [
|
|||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const response = await apiJson<PaginatedRunList>("/runs", { request });
|
||||
const apiRun = response.data.find((r) => r.id === params.id);
|
||||
if (!apiRun) return { run: null };
|
||||
|
|
@ -32,7 +31,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
export async function action({ params, request }: Route.ActionArgs) {
|
||||
export async function action({ params, request }: any) {
|
||||
const formData = await request.formData();
|
||||
const port = formData.get("port");
|
||||
const expiresInSecs = formData.get("expires_in_secs");
|
||||
|
|
@ -47,12 +46,12 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||
return result;
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
export function meta({ data }: any) {
|
||||
const run = data?.run;
|
||||
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
|
||||
}
|
||||
|
||||
export default function RunDetail({ loaderData, params }: Route.ComponentProps) {
|
||||
export default function RunDetail({ loaderData, params }: any) {
|
||||
const { run } = loaderData;
|
||||
const { pathname } = useLocation();
|
||||
const basePath = `/runs/${params.id}`;
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ import {
|
|||
type DiffLineAnnotation,
|
||||
} from "@pierre/diffs/react";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunFileList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-files";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const data = await apiJson<PaginatedRunFileList>(`/runs/${params.id}/files`, { request });
|
||||
return data;
|
||||
}
|
||||
|
|
@ -483,7 +482,7 @@ function buildAnnotationsForFile(
|
|||
return annotations;
|
||||
}
|
||||
|
||||
export default function RunFiles({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunFiles({ loaderData }: any) {
|
||||
const runFiles = loaderData;
|
||||
const files = runFiles.data.length > 0
|
||||
? runFiles.data.map((f) => ({
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
|
|||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { apiFetch, apiJson } from "../api-client";
|
||||
import { apiFetch, apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-graph";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -22,7 +21,7 @@ interface Stage {
|
|||
duration: string;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const [{ data: apiStages }, graphRes] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiFetch(`/runs/${params.id}/graph`, { request }),
|
||||
|
|
@ -190,7 +189,7 @@ function annotateRunningNodes(svg: SVGSVGElement, gt: ReturnType<typeof getGraph
|
|||
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
const DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
export default function RunGraph({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunGraph({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, graphSvg } = loaderData;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
|
|||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, PaginatedRunList, WorkflowDetail } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-overview";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ interface Stage {
|
|||
duration: string;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const [{ data: apiStages }, response] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiJson<PaginatedRunList>("/runs", { request }),
|
||||
|
|
@ -286,7 +285,7 @@ function DotDiagram({ dot }: { dot: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function RunOverview({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunOverview({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, graphDot } = loaderData;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,14 @@ import {
|
|||
formatDurationMs,
|
||||
} from "../data/retros";
|
||||
import type { Retro } from "../data/retros";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { Route } from "./+types/run-retro";
|
||||
import { apiJson } from "../api";
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const retro = await apiJson<Retro>(`/runs/${params.id}/retro`, { request });
|
||||
return { retro };
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
export function meta({ data }: any) {
|
||||
const retro = data?.retro;
|
||||
return [{ title: retro ? `Retro: ${retro.goal} \u2014 Fabro` : "Retro \u2014 Fabro" }];
|
||||
}
|
||||
|
|
@ -25,7 +24,7 @@ function formatCost(cost: number | undefined): string {
|
|||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function RunRetro({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunRetro({ loaderData }: any) {
|
||||
const { retro } = loaderData;
|
||||
|
||||
if (!retro) {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import { Link, useParams } from "react-router";
|
|||
import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, RunSettings } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-settings";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -26,7 +25,7 @@ const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: s
|
|||
cancelled: { icon: XCircleIcon, color: "text-fg-muted" },
|
||||
};
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const [{ data: apiStages }, settings] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiJson<RunSettings>(`/runs/${params.id}/settings`, { request }),
|
||||
|
|
@ -40,7 +39,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||
return { stages, settings };
|
||||
}
|
||||
|
||||
export default function RunSettingsPage({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunSettingsPage({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, settings } = loaderData;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
|
|||
import { DocumentTextIcon, MapIcon, CommandLineIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { ToolRow, ToolBlock } from "../components/tool-use";
|
||||
import type { ToolUse } from "../components/tool-use";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-stages";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ interface Stage {
|
|||
duration: string;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const { data: apiStages } = await apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request });
|
||||
const stages: Stage[] = apiStages.map((s) => ({
|
||||
id: s.id,
|
||||
|
|
@ -84,7 +83,7 @@ function AssistantBlock({ content }: { content: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function RunStages({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunStages({ loaderData }: any) {
|
||||
const { id, stageId } = useParams();
|
||||
const { stages, turns: apiTurns } = loaderData;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { RunUsage } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-usage";
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const usage = await apiJson<RunUsage>(`/runs/${params.id}/usage`, { request });
|
||||
const stages = usage.stages.map((s) => ({
|
||||
stage: s.stage.name,
|
||||
|
|
@ -33,7 +32,7 @@ function formatTokens(n: number) {
|
|||
return `${(n / 1000).toFixed(1)}k`;
|
||||
}
|
||||
|
||||
export default function RunUsage({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunUsage({ loaderData }: any) {
|
||||
const { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown } = loaderData;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -19,11 +19,10 @@ import type {
|
|||
VerificationType,
|
||||
VerificationCategory,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunVerificationList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-verification";
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const { data: apiCategories } = await apiJson<PaginatedRunVerificationList>(`/runs/${params.id}/verification`, { request });
|
||||
const categories: VerificationCategory[] = apiCategories.map((cat) => ({
|
||||
name: cat.name,
|
||||
|
|
@ -135,7 +134,7 @@ function CategoryCard({ category }: { category: VerificationCategory }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function RunVerifications({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunVerifications({ loaderData }: any) {
|
||||
const { categories } = loaderData;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -20,11 +20,10 @@ import {
|
|||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ciConfig, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs";
|
||||
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/runs";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Runs — Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +41,7 @@ const columnConfig: {
|
|||
{ id: "merge", name: "Merge", accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] },
|
||||
];
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const response = await apiJson<PaginatedRunList>("/runs", { request });
|
||||
const apiRuns = response.data;
|
||||
|
||||
|
|
@ -497,9 +496,13 @@ function SortableRunRow({ run }: { run: RunWithStatus }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function Runs({ loaderData }: Route.ComponentProps) {
|
||||
export default function Runs({ loaderData }: any) {
|
||||
const initialColumns = loaderData.columns;
|
||||
const allRepos = [...new Set(initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => item.repo)))].sort();
|
||||
const allRepos = [
|
||||
...new Set(
|
||||
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.repo))),
|
||||
),
|
||||
].sort();
|
||||
const [query, setQuery] = useState("");
|
||||
const [repoFilter, setRepoFilter] = useState("all");
|
||||
const [view, setView] = useState<ViewMode>("columns");
|
||||
|
|
@ -559,7 +562,7 @@ export default function Runs({ loaderData }: Route.ComponentProps) {
|
|||
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
>
|
||||
<option value="all">All repos</option>
|
||||
{allRepos.map((repo) => (
|
||||
{allRepos.map((repo: string) => (
|
||||
<option key={repo} value={repo}>{repo}</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -10,17 +10,16 @@ import {
|
|||
import { ToolRow, ToolBlock } from "../components/tool-use";
|
||||
import type { ToolUse } from "../components/tool-use";
|
||||
import { timeAgo, groupSessionsByDate } from "../lib/time";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { SessionDetail as ApiSessionDetail, PaginatedSessionList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/session-detail";
|
||||
|
||||
export const handle = { hideHeader: true, wide: true };
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Session — Fabro" }];
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const [apiSession, { data: apiSessions }] = await Promise.all([
|
||||
apiJson<ApiSessionDetail>(`/sessions/${params.sessionId}`, { request }),
|
||||
apiJson<PaginatedSessionList>("/sessions", { request }),
|
||||
|
|
@ -234,7 +233,7 @@ const sessions: Record<string, Session> = {
|
|||
{ id: "toolu_s3_02", toolName: "read_file", input: `{ "path": "package.json" }`, result: `"react-router-dom": "^6.22.0"`, isError: false, durationMs: 35 },
|
||||
],
|
||||
},
|
||||
{ kind: "assistant", content: "You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `Route.LoaderArgs` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes." },
|
||||
{ kind: "assistant", content: "You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `any` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes." },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -392,7 +391,7 @@ function SessionSidebar({ activeId, groups }: { activeId: string; groups: Sessio
|
|||
);
|
||||
}
|
||||
|
||||
export default function SessionDetail({ loaderData }: Route.ComponentProps) {
|
||||
export default function SessionDetail({ loaderData }: any) {
|
||||
const { session, sessionGroups: loaderGroups } = loaderData;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import type { ServerSettings } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/settings";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Settings — Fabro" }];
|
||||
}
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const settings = await apiJson<ServerSettings>("/settings", { request });
|
||||
return { settings };
|
||||
}
|
||||
|
||||
export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
export default function Settings({ loaderData }: any) {
|
||||
const { settings } = loaderData;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { redirect } from "react-router";
|
||||
import { parse, stringify } from "smol-toml";
|
||||
import { FABRO_CONFIG_PATH, reloadAppConfig } from "../lib/config.server";
|
||||
import { mergeEnv } from "../lib/merge-env";
|
||||
import type { Route } from "./+types/setup-callback";
|
||||
|
||||
const ENV_PATH = resolve(import.meta.dirname, "../../../../.env");
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
return { error: "Missing code parameter" };
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.github.com/app-manifests/${code}/conversions`,
|
||||
{ method: "POST", headers: { Accept: "application/vnd.github+json" } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return { error: `GitHub API error: ${response.status} ${body}` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
id: number;
|
||||
slug: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
webhook_secret: string;
|
||||
pem: string;
|
||||
};
|
||||
|
||||
const sessionSecret = randomBytes(32).toString("hex");
|
||||
|
||||
// Write non-secrets to TOML config
|
||||
let tomlConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
tomlConfig = parse(await readFile(FABRO_CONFIG_PATH, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
// file doesn't exist yet
|
||||
}
|
||||
tomlConfig.git = {
|
||||
...((tomlConfig.git as Record<string, unknown>) ?? {}),
|
||||
provider: "github",
|
||||
app_id: String(data.id),
|
||||
client_id: data.client_id,
|
||||
slug: data.slug,
|
||||
};
|
||||
await mkdir(dirname(FABRO_CONFIG_PATH), { recursive: true });
|
||||
await writeFile(FABRO_CONFIG_PATH, stringify(tomlConfig), "utf-8");
|
||||
reloadAppConfig();
|
||||
|
||||
// Write secrets to .env (merge to avoid duplicates on re-run)
|
||||
let existing = "";
|
||||
try {
|
||||
existing = await readFile(ENV_PATH, "utf-8");
|
||||
} catch {
|
||||
// file doesn't exist yet
|
||||
}
|
||||
|
||||
const envContent = mergeEnv(
|
||||
existing,
|
||||
new Map([
|
||||
["SESSION_SECRET", sessionSecret],
|
||||
["GITHUB_APP_CLIENT_SECRET", data.client_secret],
|
||||
["GITHUB_APP_WEBHOOK_SECRET", data.webhook_secret],
|
||||
["GITHUB_APP_PRIVATE_KEY", Buffer.from(data.pem).toString("base64")],
|
||||
]),
|
||||
);
|
||||
await writeFile(ENV_PATH, envContent, "utf-8");
|
||||
|
||||
process.env.SESSION_SECRET = sessionSecret;
|
||||
process.env.GITHUB_APP_CLIENT_SECRET = data.client_secret;
|
||||
process.env.GITHUB_APP_WEBHOOK_SECRET = data.webhook_secret;
|
||||
process.env.GITHUB_APP_PRIVATE_KEY = Buffer.from(data.pem).toString("base64");
|
||||
|
||||
throw redirect("/auth/login");
|
||||
}
|
||||
|
||||
export default function SetupCallback({ loaderData }: Route.ComponentProps) {
|
||||
const { error } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-atmosphere px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<img src="/logo.svg" alt="Fabro" className="h-12 w-12" draggable={false} />
|
||||
</div>
|
||||
<div className="rounded-xl border border-line bg-panel/80 p-8 shadow-lg backdrop-blur-sm">
|
||||
<div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-coral/10">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="text-coral">
|
||||
<path d="M7 7L13 13M13 7L7 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Setup failed
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">{error}</p>
|
||||
<a
|
||||
href="/setup"
|
||||
className="mt-6 flex w-full items-center justify-center rounded-lg border border-line-strong px-4 py-2.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay-strong"
|
||||
>
|
||||
Try again
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
apps/fabro-web/app/routes/setup-complete.tsx
Normal file
136
apps/fabro-web/app/routes/setup-complete.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
|
||||
type SetupState = "registering" | "restart_required" | "ready" | "error";
|
||||
|
||||
export default function SetupComplete() {
|
||||
const code = useMemo(() => new URLSearchParams(window.location.search).get("code"), []);
|
||||
const [state, setState] = useState<SetupState>(code ? "registering" : "restart_required");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function register() {
|
||||
if (!code) {
|
||||
setState("restart_required");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/v1/setup/register", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!cancelled) {
|
||||
setError(payload.error ?? "Setup registration failed.");
|
||||
setState("error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setState("restart_required");
|
||||
window.history.replaceState({}, "", "/setup/complete");
|
||||
}
|
||||
}
|
||||
|
||||
void register();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "restart_required") {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(async () => {
|
||||
const response = await fetch("/api/v1/setup/status", { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({ configured: false }));
|
||||
if (payload.configured) {
|
||||
setState("ready");
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
{state === "registering" && (
|
||||
<>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Finishing setup
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
Registering your GitHub App and writing local configuration.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "restart_required" && (
|
||||
<>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Setup complete
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
Fabro loads auth configuration at process start, so a restart is required before sign-in.
|
||||
</p>
|
||||
<pre className="mt-6 rounded-lg border border-line bg-panel/80 px-4 py-3 text-center font-mono text-sm text-fg">
|
||||
fabro server start
|
||||
</pre>
|
||||
<p className="mt-4 text-center text-xs text-fg-muted">
|
||||
Waiting for the restarted server to come back...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "ready" && (
|
||||
<>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Restart detected
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
Authentication is ready.
|
||||
</p>
|
||||
<a
|
||||
href="/auth/login"
|
||||
className="mt-6 flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
>
|
||||
Continue to sign in
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "error" && (
|
||||
<>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Setup failed
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
{error}
|
||||
</p>
|
||||
<a
|
||||
href="/setup"
|
||||
className="mt-6 flex w-full items-center justify-center rounded-lg border border-line-strong px-4 py-2.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay-strong"
|
||||
>
|
||||
Try again
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +1,27 @@
|
|||
import { redirect } from "react-router";
|
||||
import { useMemo } from "react";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
import { isGitHubAppConfigured } from "../lib/github.server";
|
||||
import type { Route } from "./+types/setup";
|
||||
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
if (isGitHubAppConfigured()) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const baseUrl = `${url.protocol}//${url.host}`;
|
||||
const suffix = Math.random().toString(16).slice(2, 8);
|
||||
|
||||
const manifest = {
|
||||
name: `Fabro-${suffix}`,
|
||||
url: baseUrl,
|
||||
redirect_url: `${baseUrl}/setup/callback`,
|
||||
callback_urls: [`${baseUrl}/auth/callback`],
|
||||
setup_url: `${baseUrl}/setup/callback`,
|
||||
public: false,
|
||||
default_permissions: {
|
||||
contents: "write",
|
||||
metadata: "read",
|
||||
pull_requests: "write",
|
||||
checks: "write",
|
||||
issues: "write",
|
||||
emails: "read",
|
||||
},
|
||||
default_events: [] as string[],
|
||||
};
|
||||
|
||||
return { manifest: JSON.stringify(manifest), baseUrl };
|
||||
}
|
||||
|
||||
export default function Setup({ loaderData }: Route.ComponentProps) {
|
||||
const { manifest } = loaderData;
|
||||
export default function Setup() {
|
||||
const manifest = useMemo(() => {
|
||||
const baseUrl = window.location.origin;
|
||||
const suffix = Math.random().toString(16).slice(2, 8);
|
||||
return JSON.stringify({
|
||||
name: `Fabro-${suffix}`,
|
||||
url: baseUrl,
|
||||
redirect_url: `${baseUrl}/setup/complete`,
|
||||
callback_urls: [`${baseUrl}/auth/callback/github`],
|
||||
setup_url: `${baseUrl}/setup/complete`,
|
||||
public: false,
|
||||
default_permissions: {
|
||||
contents: "write",
|
||||
metadata: "read",
|
||||
pull_requests: "write",
|
||||
checks: "write",
|
||||
issues: "write",
|
||||
emails: "read",
|
||||
},
|
||||
default_events: [],
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
|
|
|
|||
|
|
@ -20,20 +20,18 @@ import {
|
|||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link } from "react-router";
|
||||
import { apiJson, getAuthMe } from "../api";
|
||||
import { timeAgo, groupSessionsByDate } from "../lib/time";
|
||||
import { apiJson } from "../api-client";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import type { PaginatedSessionList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/start";
|
||||
|
||||
export const handle = { hideHeader: true, wide: true };
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Start — Fabro" }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { features } = getAppConfig();
|
||||
export async function loader({ request }: any) {
|
||||
const { features } = await getAuthMe();
|
||||
const { data: apiSessions } = await apiJson<PaginatedSessionList>("/sessions", { request });
|
||||
const sessionGroups = groupSessionsByDate(
|
||||
apiSessions.map((s) => ({ id: s.id, title: s.title, created_at: s.created_at }))
|
||||
|
|
@ -98,7 +96,7 @@ function SessionSidebar({ groups }: { groups: { label: string; sessions: { id: s
|
|||
);
|
||||
}
|
||||
|
||||
export default function Start({ loaderData }: Route.ComponentProps) {
|
||||
export default function Start({ loaderData }: any) {
|
||||
const { sessionGroups, features } = loaderData;
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [project, setProject] = useState(projects[0]);
|
||||
|
|
|
|||
|
|
@ -54,19 +54,18 @@ import type {
|
|||
VerificationMode,
|
||||
VerificationResult,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { timeAgo } from "../lib/time";
|
||||
import type { VerificationDetailResponse } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/verification-control";
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const data = await apiJson<VerificationDetailResponse>(`/verification/controls/${params.id}`, { request });
|
||||
return { data };
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
export function meta({ data }: any) {
|
||||
const name = data?.data?.control?.name ?? "Verification";
|
||||
return [{ title: `${name} — Verification — Fabro` }];
|
||||
}
|
||||
|
|
@ -204,7 +203,7 @@ function ResultIcon({ result }: { result: VerificationResult }) {
|
|||
return <MinusCircleIcon className={`size-4 ${config.color}`} />;
|
||||
}
|
||||
|
||||
export default function VerificationDetail({ loaderData }: Route.ComponentProps) {
|
||||
export default function VerificationDetail({ loaderData }: any) {
|
||||
const { data } = loaderData;
|
||||
const { control: controlInfo, performance: apiPerf, control_detail: apiDetail, recent_results: apiRecentResults, siblings: apiSiblings } = data;
|
||||
|
||||
|
|
|
|||
|
|
@ -40,18 +40,17 @@ import type {
|
|||
VerificationType,
|
||||
VerificationMode,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { VerificationControlListItem } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/verification-controls";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const { data: controls } = await apiJson<{ data: VerificationControlListItem[] }>("/verification/controls", { request });
|
||||
return { controls };
|
||||
}
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Controls — Verification — Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +113,7 @@ function ModeBadge({ mode }: { mode: VerificationMode }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function VerificationControls({ loaderData }: Route.ComponentProps) {
|
||||
export default function VerificationControls({ loaderData }: any) {
|
||||
const { controls } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
|
|||
|
|
@ -62,11 +62,10 @@ import type {
|
|||
VerificationCategory,
|
||||
CriterionPerformance,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { VerificationCriterion as ApiVerificationCriterion } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/verification-criteria";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const { data: apiCategories } = await apiJson<{ data: ApiVerificationCriterion[] }>("/verification/criteria", { request });
|
||||
const categories: VerificationCategory[] = apiCategories.map((cat) => ({
|
||||
name: cat.name,
|
||||
|
|
@ -96,7 +95,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Verifications — Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -385,7 +384,7 @@ function filterCategories(
|
|||
.filter((category) => category.criteria.length > 0);
|
||||
}
|
||||
|
||||
export default function Verifications({ loaderData }: Route.ComponentProps) {
|
||||
export default function Verifications({ loaderData }: any) {
|
||||
const { categories: verificationCategories, criterionPerformance } = loaderData;
|
||||
const [view, setView] = useState<ViewMode>("grouped");
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
|
|||
|
|
@ -50,18 +50,17 @@ import type {
|
|||
VerificationMode,
|
||||
VerificationResult,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { VerificationCriterionDetail } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/verification-criterion";
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const data = await apiJson<VerificationCriterionDetail>(`/verification/criteria/${params.id}`, { request });
|
||||
return { data };
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
export function meta({ data }: any) {
|
||||
const name = data?.data?.name ?? "Criterion";
|
||||
return [{ title: `${name} — Verification — Fabro` }];
|
||||
}
|
||||
|
|
@ -170,7 +169,7 @@ function EvaluationDots({ evaluations }: { evaluations: readonly VerificationRes
|
|||
);
|
||||
}
|
||||
|
||||
export default function VerificationCriterion({ loaderData }: Route.ComponentProps) {
|
||||
export default function VerificationCriterion({ loaderData }: any) {
|
||||
const { data } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { WorkflowDetail as ApiWorkflowDetail, RunSettings } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/workflow-detail";
|
||||
|
||||
export interface WorkflowEntry {
|
||||
name: string;
|
||||
|
|
@ -209,7 +208,7 @@ const tabs = [
|
|||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const apiWorkflow = await apiJson<ApiWorkflowDetail>(`/workflows/${params.name}`, { request });
|
||||
const workflow: WorkflowEntry = {
|
||||
name: apiWorkflow.name,
|
||||
|
|
@ -222,12 +221,12 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||
return { workflow };
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
export function meta({ data }: any) {
|
||||
const title = data?.workflow?.name ?? "Workflow";
|
||||
return [{ title: `${title} — Fabro` }];
|
||||
}
|
||||
|
||||
export default function WorkflowDetail({ loaderData }: Route.ComponentProps) {
|
||||
export default function WorkflowDetail({ loaderData }: any) {
|
||||
const { name } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const workflow = loaderData.workflow;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outlin
|
|||
import { Link, useParams } from "react-router";
|
||||
import { ciConfig, columnNames, deriveCiStatus, mapRunListItem, statusColors } from "../data/runs";
|
||||
import type { ColumnStatus, RunWithStatus } from "../data/runs";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/workflow-runs";
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const { data: apiRuns } = await apiJson<PaginatedRunList>(`/workflows/${params.name}/runs`, { request });
|
||||
const runs: RunWithStatus[] = apiRuns.map((r) => ({
|
||||
...mapRunListItem(r),
|
||||
|
|
@ -68,7 +67,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function WorkflowRuns({ loaderData }: Route.ComponentProps) {
|
||||
export default function WorkflowRuns({ loaderData }: any) {
|
||||
const { runs } = loaderData;
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<ColumnStatus | "all">("all");
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@ import {
|
|||
WrenchIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link } from "react-router";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { timeAgo, timeUntil } from "../lib/time";
|
||||
import type { PaginatedWorkflowList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/workflows";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Workflows — Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +104,7 @@ interface WorkflowData {
|
|||
nextRun?: string;
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const { data: apiWorkflows } = await apiJson<PaginatedWorkflowList>("/workflows", { request });
|
||||
const workflows: WorkflowData[] = apiWorkflows.map((w) => ({
|
||||
name: w.name,
|
||||
|
|
@ -202,7 +201,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
|
|||
|
||||
type TriggerFilter = "all" | "scheduled" | "manual";
|
||||
|
||||
export default function Workflows({ loaderData }: Route.ComponentProps) {
|
||||
export default function Workflows({ loaderData }: any) {
|
||||
const workflows = enrichWorkflows(loaderData.workflows);
|
||||
const [query, setQuery] = useState("");
|
||||
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("all");
|
||||
|
|
|
|||
BIN
apps/fabro-web/dist/apple-touch-icon.png
vendored
Normal file
BIN
apps/fabro-web/dist/apple-touch-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
2
apps/fabro-web/dist/assets/app.css
vendored
Normal file
2
apps/fabro-web/dist/assets/app.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-019jygp1.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-019jygp1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-019jygp1.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-019jygp1.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4
apps/fabro-web/dist/assets/chunk-02n7dnxd.js
vendored
Normal file
4
apps/fabro-web/dist/assets/chunk-02n7dnxd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-02n7dnxd.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-02n7dnxd.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-05s0gc93.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-05s0gc93.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-05s0gc93.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-05s0gc93.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-06fm9b6d.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-06fm9b6d.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-06fm9b6d.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-06fm9b6d.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-07en1qh1.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-07en1qh1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-07en1qh1.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-07en1qh1.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-09p5nycp.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-09p5nycp.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import{E as a}from"./chunk-k28qfdjw.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=B2B8C7571ED9C9F664756E2164756E21
|
||||
9
apps/fabro-web/dist/assets/chunk-09p5nycp.js.map
vendored
Normal file
9
apps/fabro-web/dist/assets/chunk-09p5nycp.js.map
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [
|
||||
],
|
||||
"mappings": "",
|
||||
"debugId": "B2B8C7571ED9C9F664756E2164756E21",
|
||||
"names": []
|
||||
}
|
||||
3
apps/fabro-web/dist/assets/chunk-0dse7fse.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-0dse7fse.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import{O as a}from"./chunk-n1k68xa8.js";import"./chunk-pectm3zk.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=A6C25A91CA86528764756E2164756E21
|
||||
9
apps/fabro-web/dist/assets/chunk-0dse7fse.js.map
vendored
Normal file
9
apps/fabro-web/dist/assets/chunk-0dse7fse.js.map
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [
|
||||
],
|
||||
"mappings": "",
|
||||
"debugId": "A6C25A91CA86528764756E2164756E21",
|
||||
"names": []
|
||||
}
|
||||
3
apps/fabro-web/dist/assets/chunk-0nc05j52.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-0nc05j52.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-0nc05j52.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-0nc05j52.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-0pazjatd.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-0pazjatd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-0pazjatd.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-0pazjatd.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-0pkns8zn.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-0pkns8zn.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import{A as a}from"./chunk-3m9zvayn.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=2BADFA3D6582DF5E64756E2164756E21
|
||||
9
apps/fabro-web/dist/assets/chunk-0pkns8zn.js.map
vendored
Normal file
9
apps/fabro-web/dist/assets/chunk-0pkns8zn.js.map
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [
|
||||
],
|
||||
"mappings": "",
|
||||
"debugId": "2BADFA3D6582DF5E64756E2164756E21",
|
||||
"names": []
|
||||
}
|
||||
3
apps/fabro-web/dist/assets/chunk-0tq24xt3.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-0tq24xt3.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-0tq24xt3.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-0tq24xt3.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-11c3h1xt.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-11c3h1xt.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-11c3h1xt.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-11c3h1xt.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4
apps/fabro-web/dist/assets/chunk-11vzjswv.js
vendored
Normal file
4
apps/fabro-web/dist/assets/chunk-11vzjswv.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-11vzjswv.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-11vzjswv.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-17r63emm.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-17r63emm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-17r63emm.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-17r63emm.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-18pbarpx.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-18pbarpx.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import{z as a}from"./chunk-mshhqkhw.js";import"./chunk-eew9p379.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=404DC8C86C76ABB464756E2164756E21
|
||||
9
apps/fabro-web/dist/assets/chunk-18pbarpx.js.map
vendored
Normal file
9
apps/fabro-web/dist/assets/chunk-18pbarpx.js.map
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [
|
||||
],
|
||||
"mappings": "",
|
||||
"debugId": "404DC8C86C76ABB464756E2164756E21",
|
||||
"names": []
|
||||
}
|
||||
3
apps/fabro-web/dist/assets/chunk-1ezq7eg5.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-1ezq7eg5.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import{d as a}from"./chunk-wzb9zsp9.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=B3D290E5FB496EF464756E2164756E21
|
||||
9
apps/fabro-web/dist/assets/chunk-1ezq7eg5.js.map
vendored
Normal file
9
apps/fabro-web/dist/assets/chunk-1ezq7eg5.js.map
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [
|
||||
],
|
||||
"mappings": "",
|
||||
"debugId": "B3D290E5FB496EF464756E2164756E21",
|
||||
"names": []
|
||||
}
|
||||
3
apps/fabro-web/dist/assets/chunk-1jb7a4x7.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-1jb7a4x7.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-1jb7a4x7.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-1jb7a4x7.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-1wjv325z.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-1wjv325z.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import{u as a}from"./chunk-4v5fshv2.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=0D1B315872C84EA364756E2164756E21
|
||||
9
apps/fabro-web/dist/assets/chunk-1wjv325z.js.map
vendored
Normal file
9
apps/fabro-web/dist/assets/chunk-1wjv325z.js.map
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [
|
||||
],
|
||||
"mappings": "",
|
||||
"debugId": "0D1B315872C84EA364756E2164756E21",
|
||||
"names": []
|
||||
}
|
||||
4
apps/fabro-web/dist/assets/chunk-1xpescw2.js
vendored
Normal file
4
apps/fabro-web/dist/assets/chunk-1xpescw2.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-1xpescw2.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-1xpescw2.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-1z6jks96.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-1z6jks96.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-1z6jks96.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-1z6jks96.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-207mkg9t.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-207mkg9t.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import{v as e}from"./chunk-8mhds0wh.js";import"./chunk-q07bg6gn.js";var t=Object.freeze(JSON.parse('{"displayName":"Git Commit Message","name":"git-commit","patterns":[{"begin":"(?=^diff --git)","contentName":"source.diff","end":"\\\\z","name":"meta.embedded.diff.git-commit","patterns":[{"include":"source.diff"}]},{"begin":"^(?!#)","end":"^(?=#)","name":"meta.scope.message.git-commit","patterns":[{"captures":{"1":{"name":"invalid.deprecated.line-too-long.git-commit"},"2":{"name":"invalid.illegal.line-too-long.git-commit"}},"match":"\\\\G.{0,50}(.{0,22}(.*))$","name":"meta.scope.subject.git-commit"}]},{"begin":"^(?=#)","contentName":"comment.line.number-sign.git-commit","end":"^(?!#)","name":"meta.scope.metadata.git-commit","patterns":[{"captures":{"1":{"name":"markup.changed.git-commit"}},"match":"^#\\\\t((modified|renamed):.*)$"},{"captures":{"1":{"name":"markup.inserted.git-commit"}},"match":"^#\\\\t(new file:.*)$"},{"captures":{"1":{"name":"markup.deleted.git-commit"}},"match":"^#\\\\t(deleted.*)$"},{"captures":{"1":{"name":"keyword.other.file-type.git-commit"},"2":{"name":"string.unquoted.filename.git-commit"}},"match":"^#\\\\t([^:]+): *(.*)$"}]}],"scopeName":"text.git-commit","embeddedLangs":["diff"]}')),i=[...e,t];export{i as default};
|
||||
|
||||
//# debugId=FF6F1DB5E1831DE564756E2164756E21
|
||||
10
apps/fabro-web/dist/assets/chunk-207mkg9t.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-207mkg9t.js.map
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": ["../../../../node_modules/.bun/@shikijs+langs@3.23.0/node_modules/@shikijs/langs/dist/git-commit.mjs"],
|
||||
"sourcesContent": [
|
||||
"import diff from './diff.mjs'\n\nconst lang = Object.freeze(JSON.parse(\"{\\\"displayName\\\":\\\"Git Commit Message\\\",\\\"name\\\":\\\"git-commit\\\",\\\"patterns\\\":[{\\\"begin\\\":\\\"(?=^diff --git)\\\",\\\"contentName\\\":\\\"source.diff\\\",\\\"end\\\":\\\"\\\\\\\\z\\\",\\\"name\\\":\\\"meta.embedded.diff.git-commit\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"source.diff\\\"}]},{\\\"begin\\\":\\\"^(?!#)\\\",\\\"end\\\":\\\"^(?=#)\\\",\\\"name\\\":\\\"meta.scope.message.git-commit\\\",\\\"patterns\\\":[{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"invalid.deprecated.line-too-long.git-commit\\\"},\\\"2\\\":{\\\"name\\\":\\\"invalid.illegal.line-too-long.git-commit\\\"}},\\\"match\\\":\\\"\\\\\\\\G.{0,50}(.{0,22}(.*))$\\\",\\\"name\\\":\\\"meta.scope.subject.git-commit\\\"}]},{\\\"begin\\\":\\\"^(?=#)\\\",\\\"contentName\\\":\\\"comment.line.number-sign.git-commit\\\",\\\"end\\\":\\\"^(?!#)\\\",\\\"name\\\":\\\"meta.scope.metadata.git-commit\\\",\\\"patterns\\\":[{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"markup.changed.git-commit\\\"}},\\\"match\\\":\\\"^#\\\\\\\\t((modified|renamed):.*)$\\\"},{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"markup.inserted.git-commit\\\"}},\\\"match\\\":\\\"^#\\\\\\\\t(new file:.*)$\\\"},{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"markup.deleted.git-commit\\\"}},\\\"match\\\":\\\"^#\\\\\\\\t(deleted.*)$\\\"},{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"keyword.other.file-type.git-commit\\\"},\\\"2\\\":{\\\"name\\\":\\\"string.unquoted.filename.git-commit\\\"}},\\\"match\\\":\\\"^#\\\\\\\\t([^:]+): *(.*)$\\\"}]}],\\\"scopeName\\\":\\\"text.git-commit\\\",\\\"embeddedLangs\\\":[\\\"diff\\\"]}\"))\n\nexport default [\n...diff,\nlang\n]\n"
|
||||
],
|
||||
"mappings": "0EAEA,FAAM,EAAO,OAAO,OAAO,KAAK,MAAM,umCAA2vC,CAAC,EAEnxC,GACf,GAAG,EACH,CACA",
|
||||
"debugId": "FF6F1DB5E1831DE564756E2164756E21",
|
||||
"names": []
|
||||
}
|
||||
3
apps/fabro-web/dist/assets/chunk-2cjhppva.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-2cjhppva.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import"./chunk-q07bg6gn.js";var e=Object.freeze(JSON.parse('{"displayName":"Fluent","name":"fluent","patterns":[{"include":"#comment"},{"include":"#message"},{"include":"#wrong-line"}],"repository":{"attributes":{"begin":"\\\\s*(\\\\.[A-Za-z][-0-9A-Z_a-z]*\\\\s*=\\\\s*)","beginCaptures":{"1":{"name":"support.class.attribute-begin.fluent"}},"end":"^(?=\\\\s*[^.])","patterns":[{"include":"#placeable"}]},"comment":{"match":"^##?#?\\\\s.*$","name":"comment.fluent"},"function-comma":{"match":",","name":"support.function.function-comma.fluent"},"function-named-argument":{"begin":"([0-9A-Za-z]+:)\\\\s*([\\"0-9A-Za-z]+)","beginCaptures":{"1":{"name":"support.function.named-argument.name.fluent"},"2":{"name":"variable.other.named-argument.value.fluent"}},"end":"(?=[),\\\\s])","name":"variable.other.named-argument.fluent"},"function-positional-argument":{"match":"\\\\$[-0-9A-Z_a-z]+","name":"variable.other.function.positional-argument.fluent"},"invalid-placeable-string-missing-end-quote":{"match":"\\"[^\\"]+$","name":"invalid.illegal.wrong-placeable-missing-end-quote.fluent"},"invalid-placeable-wrong-placeable-missing-end":{"match":"([^A-Z}]*|[^-][^>])$\\\\b","name":"invalid.illegal.wrong-placeable-missing-end.fluent"},"message":{"begin":"^(-?[A-Za-z][-0-9A-Z_a-z]*\\\\s*=\\\\s*)","beginCaptures":{"1":{"name":"support.class.message-identifier.fluent"}},"contentName":"string.fluent","end":"^(?=\\\\S)","patterns":[{"include":"#attributes"},{"include":"#placeable"}]},"placeable":{"begin":"(\\\\{)","beginCaptures":{"1":{"name":"keyword.placeable.begin.fluent"}},"contentName":"variable.other.placeable.content.fluent","end":"(})","endCaptures":{"1":{"name":"keyword.placeable.end.fluent"}},"patterns":[{"include":"#placeable-string"},{"include":"#placeable-function"},{"include":"#placeable-reference-or-number"},{"include":"#selector"},{"include":"#invalid-placeable-wrong-placeable-missing-end"},{"include":"#invalid-placeable-string-missing-end-quote"},{"include":"#invalid-placeable-wrong-function-name"}]},"placeable-function":{"begin":"([A-Z][-0-9A-Z_]*\\\\()","beginCaptures":{"1":{"name":"support.function.placeable-function.call.begin.fluent"}},"contentName":"string.placeable-function.fluent","end":"(\\\\))","endCaptures":{"1":{"name":"support.function.placeable-function.call.end.fluent"}},"patterns":[{"include":"#function-comma"},{"include":"#function-positional-argument"},{"include":"#function-named-argument"}]},"placeable-reference-or-number":{"match":"(([-$])[-0-9A-Z_a-z]+|[A-Za-z][-0-9A-Z_a-z]*|[0-9]+)","name":"variable.other.placeable.reference-or-number.fluent"},"placeable-string":{"begin":"(\\")(?=[^\\\\n]*\\")","beginCaptures":{"1":{"name":"variable.other.placeable-string-begin.fluent"}},"contentName":"string.placeable-string-content.fluent","end":"(\\")","endCaptures":{"1":{"name":"variable.other.placeable-string-end.fluent"}}},"selector":{"begin":"(->)","beginCaptures":{"1":{"name":"support.function.selector.begin.fluent"}},"contentName":"string.selector.content.fluent","end":"^(?=\\\\s*})","patterns":[{"include":"#selector-item"}]},"selector-item":{"begin":"(\\\\s*\\\\*?\\\\[)([-0-9A-Z_a-z]+)(]\\\\s*)","beginCaptures":{"1":{"name":"support.function.selector-item.begin.fluent"},"2":{"name":"variable.other.selector-item.begin.fluent"},"3":{"name":"support.function.selector-item.begin.fluent"}},"contentName":"string.selector-item.content.fluent","end":"^(?=(\\\\s*})|(\\\\s*\\\\[)|(\\\\s*\\\\*))","patterns":[{"include":"#placeable"}]},"wrong-line":{"match":".*","name":"invalid.illegal.wrong-line.fluent"}},"scopeName":"source.ftl","aliases":["ftl"]}')),n=[e];export{n as default};
|
||||
|
||||
//# debugId=ECEE1DD8C17D275564756E2164756E21
|
||||
10
apps/fabro-web/dist/assets/chunk-2cjhppva.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-2cjhppva.js.map
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": ["../../../../node_modules/.bun/@shikijs+langs@3.23.0/node_modules/@shikijs/langs/dist/fluent.mjs"],
|
||||
"sourcesContent": [
|
||||
"const lang = Object.freeze(JSON.parse(\"{\\\"displayName\\\":\\\"Fluent\\\",\\\"name\\\":\\\"fluent\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#comment\\\"},{\\\"include\\\":\\\"#message\\\"},{\\\"include\\\":\\\"#wrong-line\\\"}],\\\"repository\\\":{\\\"attributes\\\":{\\\"begin\\\":\\\"\\\\\\\\s*(\\\\\\\\.[A-Za-z][-0-9A-Z_a-z]*\\\\\\\\s*=\\\\\\\\s*)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.class.attribute-begin.fluent\\\"}},\\\"end\\\":\\\"^(?=\\\\\\\\s*[^.])\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#placeable\\\"}]},\\\"comment\\\":{\\\"match\\\":\\\"^##?#?\\\\\\\\s.*$\\\",\\\"name\\\":\\\"comment.fluent\\\"},\\\"function-comma\\\":{\\\"match\\\":\\\",\\\",\\\"name\\\":\\\"support.function.function-comma.fluent\\\"},\\\"function-named-argument\\\":{\\\"begin\\\":\\\"([0-9A-Za-z]+:)\\\\\\\\s*([\\\\\\\"0-9A-Za-z]+)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.named-argument.name.fluent\\\"},\\\"2\\\":{\\\"name\\\":\\\"variable.other.named-argument.value.fluent\\\"}},\\\"end\\\":\\\"(?=[),\\\\\\\\s])\\\",\\\"name\\\":\\\"variable.other.named-argument.fluent\\\"},\\\"function-positional-argument\\\":{\\\"match\\\":\\\"\\\\\\\\$[-0-9A-Z_a-z]+\\\",\\\"name\\\":\\\"variable.other.function.positional-argument.fluent\\\"},\\\"invalid-placeable-string-missing-end-quote\\\":{\\\"match\\\":\\\"\\\\\\\"[^\\\\\\\"]+$\\\",\\\"name\\\":\\\"invalid.illegal.wrong-placeable-missing-end-quote.fluent\\\"},\\\"invalid-placeable-wrong-placeable-missing-end\\\":{\\\"match\\\":\\\"([^A-Z}]*|[^-][^>])$\\\\\\\\b\\\",\\\"name\\\":\\\"invalid.illegal.wrong-placeable-missing-end.fluent\\\"},\\\"message\\\":{\\\"begin\\\":\\\"^(-?[A-Za-z][-0-9A-Z_a-z]*\\\\\\\\s*=\\\\\\\\s*)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.class.message-identifier.fluent\\\"}},\\\"contentName\\\":\\\"string.fluent\\\",\\\"end\\\":\\\"^(?=\\\\\\\\S)\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#attributes\\\"},{\\\"include\\\":\\\"#placeable\\\"}]},\\\"placeable\\\":{\\\"begin\\\":\\\"(\\\\\\\\{)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"keyword.placeable.begin.fluent\\\"}},\\\"contentName\\\":\\\"variable.other.placeable.content.fluent\\\",\\\"end\\\":\\\"(})\\\",\\\"endCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"keyword.placeable.end.fluent\\\"}},\\\"patterns\\\":[{\\\"include\\\":\\\"#placeable-string\\\"},{\\\"include\\\":\\\"#placeable-function\\\"},{\\\"include\\\":\\\"#placeable-reference-or-number\\\"},{\\\"include\\\":\\\"#selector\\\"},{\\\"include\\\":\\\"#invalid-placeable-wrong-placeable-missing-end\\\"},{\\\"include\\\":\\\"#invalid-placeable-string-missing-end-quote\\\"},{\\\"include\\\":\\\"#invalid-placeable-wrong-function-name\\\"}]},\\\"placeable-function\\\":{\\\"begin\\\":\\\"([A-Z][-0-9A-Z_]*\\\\\\\\()\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.placeable-function.call.begin.fluent\\\"}},\\\"contentName\\\":\\\"string.placeable-function.fluent\\\",\\\"end\\\":\\\"(\\\\\\\\))\\\",\\\"endCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.placeable-function.call.end.fluent\\\"}},\\\"patterns\\\":[{\\\"include\\\":\\\"#function-comma\\\"},{\\\"include\\\":\\\"#function-positional-argument\\\"},{\\\"include\\\":\\\"#function-named-argument\\\"}]},\\\"placeable-reference-or-number\\\":{\\\"match\\\":\\\"(([-$])[-0-9A-Z_a-z]+|[A-Za-z][-0-9A-Z_a-z]*|[0-9]+)\\\",\\\"name\\\":\\\"variable.other.placeable.reference-or-number.fluent\\\"},\\\"placeable-string\\\":{\\\"begin\\\":\\\"(\\\\\\\")(?=[^\\\\\\\\n]*\\\\\\\")\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"variable.other.placeable-string-begin.fluent\\\"}},\\\"contentName\\\":\\\"string.placeable-string-content.fluent\\\",\\\"end\\\":\\\"(\\\\\\\")\\\",\\\"endCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"variable.other.placeable-string-end.fluent\\\"}}},\\\"selector\\\":{\\\"begin\\\":\\\"(->)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.selector.begin.fluent\\\"}},\\\"contentName\\\":\\\"string.selector.content.fluent\\\",\\\"end\\\":\\\"^(?=\\\\\\\\s*})\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#selector-item\\\"}]},\\\"selector-item\\\":{\\\"begin\\\":\\\"(\\\\\\\\s*\\\\\\\\*?\\\\\\\\[)([-0-9A-Z_a-z]+)(]\\\\\\\\s*)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.selector-item.begin.fluent\\\"},\\\"2\\\":{\\\"name\\\":\\\"variable.other.selector-item.begin.fluent\\\"},\\\"3\\\":{\\\"name\\\":\\\"support.function.selector-item.begin.fluent\\\"}},\\\"contentName\\\":\\\"string.selector-item.content.fluent\\\",\\\"end\\\":\\\"^(?=(\\\\\\\\s*})|(\\\\\\\\s*\\\\\\\\[)|(\\\\\\\\s*\\\\\\\\*))\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#placeable\\\"}]},\\\"wrong-line\\\":{\\\"match\\\":\\\".*\\\",\\\"name\\\":\\\"invalid.illegal.wrong-line.fluent\\\"}},\\\"scopeName\\\":\\\"source.ftl\\\",\\\"aliases\\\":[\\\"ftl\\\"]}\"))\n\nexport default [\nlang\n]\n"
|
||||
],
|
||||
"mappings": "4BAAA,IAAM,EAAO,OAAO,OAAO,KAAK,MAAM,09GAAo2H,CAAC,EAE53H,GACf,CACA",
|
||||
"debugId": "ECEE1DD8C17D275564756E2164756E21",
|
||||
"names": []
|
||||
}
|
||||
3
apps/fabro-web/dist/assets/chunk-2d0xan0e.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-2d0xan0e.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-2d0xan0e.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-2d0xan0e.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-2eh3eq47.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-2eh3eq47.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-2eh3eq47.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-2eh3eq47.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
apps/fabro-web/dist/assets/chunk-2ndh3q6f.js
vendored
Normal file
3
apps/fabro-web/dist/assets/chunk-2ndh3q6f.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-2ndh3q6f.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-2ndh3q6f.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue