This commit is contained in:
Michael Zhang 2024-11-30 17:00:53 -06:00
commit a2bc2fdeda
21 changed files with 3343 additions and 0 deletions

1
backend/.env Normal file
View file

@ -0,0 +1 @@
DATABASE_URL=sqlite://test.db

2
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
test.db*

2652
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
backend/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.93"
axum = "0.7.9"
futures = "0.3.31"
reqwest = { version = "0.12.9", features = ["json", "stream"] }
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.133"
sqlx = { version = "0.8.2", features = ["json", "runtime-tokio", "sqlite"] }
tokio = { version = "1.41.1", features = ["full"] }
tokio-util = { version = "0.7.12", features = ["codec", "io"] }

View file

@ -0,0 +1,2 @@
DROP TABLE blocks;
DROP TABLE connections;

View file

@ -0,0 +1,24 @@
-- Add up migration script here
CREATE TABLE blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
options TEXT NOT NULL
);
CREATE TABLE connections (
fromId INTEGER NOT NULL,
fromKey TEXT NOT NULL,
toId INTEGER NOT NULL,
toKey TEXT NOT NULL,
FOREIGN KEY (fromId) REFERENCES blocks(id),
FOREIGN KEY (toId) REFERENCES blocks(id)
);
CREATE TABLE runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
blockId INTEGER NOT NULL,
status TEXT NOT NULL,
info TEXT NOT NULL,
FOREIGN KEY (blockId) REFERENCES blocks(id)
);

2
backend/rustfmt.toml Normal file
View file

@ -0,0 +1,2 @@
tab_spaces = 2
max_width = 80

82
backend/src/block.rs Normal file
View file

@ -0,0 +1,82 @@
use axum::{
extract::{Path, State},
Json,
};
use serde_json::Value;
use sqlx::{prelude::FromRow, types::Json as SqlxJson, Row};
use crate::{error::AppResult, AppState};
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Block {
pub id: i64,
pub kind: String,
pub options: SqlxJson<Value>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockOptions {
#[serde(default)]
inputs: Vec<BlockInput>,
#[serde(flatten)]
extra_options: Value,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockInput {
name: String,
datatype: Option<String>,
}
pub async fn list_blocks(
state: State<AppState>,
) -> AppResult<Json<Vec<Block>>> {
let results = sqlx::query_as::<_, Block>(r"SELECT * FROM blocks")
.fetch_all(&state.db)
.await?;
Ok(Json(results))
}
pub async fn create_block(state: State<AppState>) -> AppResult<Json<i64>> {
let id = sqlx::query(
r#"INSERT INTO blocks (kind, options) VALUES (?, ?) RETURNING id"#,
)
.bind("helloge")
.bind(json!({}))
.fetch_one(&state.db)
.await?;
let id: i64 = id.get(0);
Ok(Json(id))
}
pub async fn get_block(
state: State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Block>> {
let result = sqlx::query_as::<_, Block>(r"SELECT * FROM blocks WHERE id = ?")
.bind(id)
.fetch_one(&state.db)
.await?;
Ok(Json(result))
}
pub async fn update_block() -> AppResult {
Ok(())
}
pub async fn delete_block(
state: State<AppState>,
Path(id): Path<i64>,
) -> AppResult {
sqlx::query(r"DELETE FROM blocks WHERE id = ?")
.bind(id)
.execute(&state.db)
.await?;
Ok(())
}

31
backend/src/error.rs Normal file
View file

@ -0,0 +1,31 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
pub type AppResult<T = (), E = AppError> = std::result::Result<T, E>;
// Make our own error that wraps `anyhow::Error`.
pub struct AppError(anyhow::Error);
// Tell axum how to convert `AppError` into a response.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", self.0),
)
.into_response()
}
}
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
// `Result<_, AppError>`. That way you don't need to do that manually.
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

83
backend/src/main.rs Normal file
View file

@ -0,0 +1,83 @@
#[macro_use]
extern crate serde;
#[macro_use]
extern crate serde_json;
mod block;
mod error;
mod worker;
use anyhow::Result;
use axum::{
routing::{delete, get, post},
Router,
};
use sqlx::{
migrate,
sqlite::{SqliteConnectOptions, SqlitePoolOptions, UpdateHookResult},
SqlitePool,
};
use tokio::{
net::TcpListener,
sync::mpsc::{self},
};
use crate::block::{
create_block, delete_block, get_block, list_blocks, update_block,
};
use crate::worker::worker;
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
}
#[derive(Debug)]
pub struct DatabaseUpdate {}
#[tokio::main]
async fn main() -> Result<()> {
let (tx, database_updates) = mpsc::unbounded_channel();
let db = {
let options = SqliteConnectOptions::new()
.filename("test.db")
.create_if_missing(true);
SqlitePoolOptions::new()
.after_connect(move |c, md| {
let tx = tx.clone();
Box::pin(async {
let mut c = c.lock_handle().await?;
c.set_update_hook(move |u: UpdateHookResult<'_>| {
if u.table == "blocks" {
tx.send(DatabaseUpdate {}).unwrap();
}
});
Ok(())
})
})
.connect_with(options)
.await?
};
migrate!().run(&db).await?;
let state = AppState { db };
tokio::spawn(worker(state.clone(), database_updates));
let app = Router::new()
// Block CRUD
.route("/block", get(list_blocks))
.route("/block", post(create_block))
.route("/block/:id", get(get_block))
.route("/block/:id", post(update_block))
.route("/block/:id", delete(delete_block))
.with_state(state);
let listener = TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}

71
backend/src/worker.rs Normal file
View file

@ -0,0 +1,71 @@
use std::str::FromStr;
use anyhow::Result;
use futures::TryStreamExt;
use serde_json::Value;
use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender},
task::JoinHandle,
};
use tokio_util::{
codec::{FramedRead, LinesCodec},
io::StreamReader,
};
use crate::{block::Block, AppState, DatabaseUpdate};
pub async fn worker(
state: AppState,
mut database_updates: UnboundedReceiver<DatabaseUpdate>,
) {
let mut prev_handle: Option<JoinHandle<()>> = None;
loop {
let upd = database_updates.recv().await;
println!("Update: {upd:?}");
if let Some(handle) = prev_handle.take() {
handle.abort();
}
let state = state.clone();
prev_handle = Some(tokio::spawn(async move {
run(state).await;
}));
}
}
async fn run(state: AppState) -> Result<()> {
let blocks = sqlx::query_as::<_, Block>(r"SELECT * FROM blocks")
.fetch_all(&state.db)
.await?;
println!("blocks: {blocks:?}");
for block in blocks {
if block.kind == "ntfy" {
tokio::spawn(run_ntfy_listener());
}
}
Ok(())
}
async fn run_ntfy_listener() {
// TODO: Exponential backoff retry
let res = reqwest::get("https://ntfy.sh/LIXE61yAwoClnxyL/json")
.await
.unwrap();
let stream = res.bytes_stream().map_err(std::io::Error::other);
let stream = StreamReader::new(stream);
let mut stream = FramedRead::new(stream, LinesCodec::new());
while let Ok(Some(line)) = stream.try_next().await {
let line = Value::from_str(&line).unwrap();
println!("line: {line:?}");
// TODO: Push this event to the database
// TODO: Get a list of all of the connections from the database
// TODO: Push a new task for each of the new connections into the database
}
}

175
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
frontend/README.md Normal file
View file

@ -0,0 +1,15 @@
# frontend
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.1.29. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

BIN
frontend/bun.lockb Normal file

Binary file not shown.

7
frontend/index.html Normal file
View file

@ -0,0 +1,7 @@
<!doctype html>
<html lang="en">
<body>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

43
frontend/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "frontend",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "latest",
"@types/cookie": "^1.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"vite": "^6.0.1"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@mantine/carousel": "^7.14.3",
"@mantine/charts": "^7.14.3",
"@mantine/code-highlight": "^7.14.3",
"@mantine/core": "^7.14.3",
"@mantine/dates": "^7.14.3",
"@mantine/dropzone": "^7.14.3",
"@mantine/form": "^7.14.3",
"@mantine/hooks": "^7.14.3",
"@mantine/modals": "^7.14.3",
"@mantine/notifications": "^7.14.3",
"@mantine/nprogress": "^7.14.3",
"@mantine/spotlight": "^7.14.3",
"@mantine/tiptap": "^7.14.3",
"@tanstack/react-query": "^5.62.0",
"@tiptap/extension-link": "^2.10.3",
"@tiptap/pm": "^2.10.3",
"@tiptap/react": "^2.10.3",
"@tiptap/starter-kit": "^2.10.3",
"dayjs": "^1.11.13",
"embla-carousel-react": "^7.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "2"
}
}

View file

@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

74
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,74 @@
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import "@mantine/core/styles.css";
import { MantineProvider } from "@mantine/core";
const queryClient = new QueryClient();
function Block({ block }) {
const qc = useQueryClient();
const { mutate: deleteBlock } = useMutation({
mutationFn: async () => {
await fetch(`/api/block/${block.id}`, { method: "DELETE" });
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["block/list"] });
},
});
return (
<li>
{JSON.stringify(block)} (
<button onClick={() => deleteBlock()}>delete</button>)
</li>
);
}
function BlockList() {
const { data: blocks, status } = useQuery({
queryKey: ["block/list"],
queryFn: async () => (await fetch("/api/block")).json(),
});
if (status !== "success") return <>{status}</>;
return (
<ul>
{blocks.map((block) => (
<Block key={block.id} block={block} />
))}
</ul>
);
}
function CreateButton() {
const qc = useQueryClient();
const { mutate: createBlock } = useMutation({
mutationFn: async () => {
await fetch(`/api/block`, { method: "POST" });
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["block/list"] });
},
});
return <button onClick={() => createBlock()}>create</button>;
}
export default function App() {
return (
<MantineProvider>
<QueryClientProvider client={queryClient}>
<BlockList />
<CreateButton />
</QueryClientProvider>
</MantineProvider>
);
}

10
frontend/src/index.tsx Normal file
View file

@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import App from "./App";
const root = document.getElementById("root");
if (!root) {
throw new Error("could not create root");
}
createRoot(root).render(<App />);

28
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
}
}

12
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:3000",
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});