diff --git a/client/package.json b/client/package.json index 9312ad5..f410ede 100644 --- a/client/package.json +++ b/client/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vue-tsc && vite build", + "watch": "vue-tsc --watch & sleep 3 && vite build --watch", "preview": "vite preview" }, "dependencies": { diff --git a/client/src/App.vue b/client/src/App.vue index 79e2f7f..b7adb44 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,9 +1,14 @@ diff --git a/client/src/components/Table.vue b/client/src/components/Table.vue index c92b646..338bd0b 100644 --- a/client/src/components/Table.vue +++ b/client/src/components/Table.vue @@ -8,7 +8,7 @@ {{ dateString(date) }} - + diff --git a/client/src/components/TickComponent.vue b/client/src/components/TickComponent.vue index cd3ffcb..29e7abc 100644 --- a/client/src/components/TickComponent.vue +++ b/client/src/components/TickComponent.vue @@ -1,23 +1,22 @@ \ No newline at end of file diff --git a/client/src/state.ts b/client/src/state.ts index 2b03738..90f7c2b 100644 --- a/client/src/state.ts +++ b/client/src/state.ts @@ -2,32 +2,87 @@ import { reactive } from "vue" import { Track } from "./track" import { error } from "./error" +enum State { + Unfetched, + Fetching, + Fetched, +} + export const state = reactive({ tracks: new Array, - isPopulating: false, - async populate() { - if (this.isPopulating) return - this.isPopulating = true + state: State.Unfetched, + streamUpdatesFromServer() { + const source = new EventSource("/api/v1/updates") + source.addEventListener("open", () => console.debug("opened event source")) + source.addEventListener('message', event => console.log(event)) + source.addEventListener('TickAdded', event => { + console.log(event) + const tick: Tick = JSON.parse(event.data) + for (const track of this.tracks) { + if (track.id === tick.track_id) { + console.debug('pushing tick') + track.ticks?.push(tick) + } + } + // this.tracks = this.tracks.map(track => { + // if (track.id === tick.track_id) { + // const ticks = track.ticks ?? [] + // ticks.push(tick) + // track.ticks = ticks + // } + // return track + // }) + }) + source.addEventListener('TickDropped', event => { + console.log(event) + const tick: Tick = JSON.parse(event.data) + for (const track of this.tracks) + if (track.id === tick.track_id) + track.ticks = track.ticks?.filter($tick => $tick.id === tick.id) + // this.tracks = this.tracks.map(track => { + // if (track.id === tick.track_id) { + // track.ticks = track.ticks?.filter($tick => $tick.id === tick.id) + // } + // return track + // }) + }) + source.addEventListener('Lagged', event => { + console.log(event) + // Refresh the page, refetching the list of tracks and ticks + window.location = window.location + }) + source.addEventListener('error', event => { + error(event) + window.location = window.location + }) + }, + async repopulate() { + this.state = State.Fetching this.tracks = await Track.fetchAll() - this.isPopulating = false }, - async taskCompleted(track: Track): Promise { - const result = await fetch(`/api/v1/tracks/${track.id}/ticked`, { method: "PATCH" }) - const body = await result.text() - if (!result.ok) { + async populate() { + if (this.state != State.Unfetched) return + await this.repopulate() + this.streamUpdatesFromServer() + this.state = State.Fetched + }, + async taskCompleted(track: Track, date: Date): Promise { + let query = new URLSearchParams() + query.append("year", date.getUTCFullYear().toString()) + query.append("month", (date.getUTCMonth() + 1).toString()) + // good thing I still had this ^^^^^^^^^^^^^^ in mind when I wrote this 😬 + query.append("day", date.getUTCDate().toString()) + const response: Response = await fetch(`/api/v1/tracks/${track.id}/ticked?${query.toString()}`, { method: "PATCH" }) + const body = await response.text() + if (!response.ok) { error(body) - throw new Error(`error setting tick for track ${track.id} ("${track.name}"): ${result.status} ${result.statusText}`) + throw new Error(`error setting tick for track ${track.id} ("${track.name}"): ${response.status} ${response.statusText}`) } - const tick: Tick = JSON.parse(body) - track.ticks = track.ticks ?? [] - track.ticks.push(tick) - const tracks = this.tracks.map($track => track.id === $track.id ? track : $track) - this.tracks = tracks - return tick + return JSON.parse(body) }, - async taskMarkedIncomplete(tick: Tick) { - const { ok, status, statusText } = await fetch(`/api/v1/ticks/${tick.id}`, { method: 'DELETE' }) + async taskMarkedIncomplete(track: Track) { + const { ok, status, statusText } = await fetch(`/api/v1/tracks/${track.id}/all-ticks`, { method: 'DELETE' }) if (!ok) - error(`error deleting tick ${tick.id}: ${statusText} (${status})`) + error(`error deleting ticks for ${track.id}: ${statusText} (${status})`) } -}) \ No newline at end of file +}) diff --git a/client/src/ticks.ts b/client/src/ticks.ts index 1cd3e34..895682b 100644 --- a/client/src/ticks.ts +++ b/client/src/ticks.ts @@ -58,8 +58,8 @@ class Tick implements ITick { date(): Date | null { if (this.year && this.month && this.day && this.hour && this.minute && this.second) { - return null + return new Date(this.year!, this.month!, this.day, this.hour, this.minute, this.second) } - return new Date(this.year!, this.month!, this.day, this.hour, this.minute, this.second) + return null } } diff --git a/client/src/track.ts b/client/src/track.ts index 13c2174..14f7567 100644 --- a/client/src/track.ts +++ b/client/src/track.ts @@ -55,7 +55,8 @@ export class Track implements ITrack { for (var tick of (this.ticks ?? [])) { if ( date.getUTCFullYear() == tick.year && - date.getUTCMonth() == tick.month && + (date.getUTCMonth() + 1) == tick.month && + // Javascript Date ^^^ uses 0-index for dates of months 🤦 date.getDate() == tick.day ) return true } diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index f1e488e..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,43 +0,0 @@ -version: "3.5" - -services: - server: - # build: ./server - build: - context: ./server - dockerfile: Dockerfile.debug - networks: - - web - - internal - environment: - POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password - POSTGRES_USER: kalkulog - POSTGRES_DB: kalkulog - POSTGRES_HOST: database - secrets: [ postgres-password ] - depends_on: [ database ] - ports: - # TODO remove in prod - - 8000:8000 - volumes: - - ./client/dist:/src/public:ro - database: - image: postgres - environment: - POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password - POSTGRES_USER: kalkulog - POSTGRES_DB: kalkulog - secrets: [ postgres-password ] - networks: [ internal ] - volumes: - - ./db.mount:/var/lib/postgresql/data - -secrets: - postgres-password: - file: ./server/postgres.pw - -networks: - internal: - internal: true - web: - external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 120000 index 0000000..9f1a63f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1 @@ +docker-compose_dev.yml \ No newline at end of file diff --git a/docker-compose_dev.yml b/docker-compose_dev.yml new file mode 100644 index 0000000..780b9be --- /dev/null +++ b/docker-compose_dev.yml @@ -0,0 +1,75 @@ +version: "3.5" + +services: + server: + # build: ./server + build: + context: ./server + dockerfile: Dockerfile.debug + networks: + - web + - internal + environment: + POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password + POSTGRES_USER: kalkutago + POSTGRES_DB: kalkutago + POSTGRES_HOST: database + secrets: [ postgres-password ] + depends_on: [ database ] + expose: [ 8000 ] + # ports: + # # TODO remove in prod + # - 8000:8000 + volumes: + - ./client/dist:/src/public:ro + labels: + traefik.enable: true + traefik.http.routers.kalkutago_server.rule: 'Host(`kalkutago`) && PathPrefix(`/api`)' + database: + image: postgres + environment: + POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password + POSTGRES_USER: kalkutago + POSTGRES_DB: kalkutago + secrets: [ postgres-password ] + networks: [ internal ] + volumes: + - ./db.mount:/var/lib/postgresql/data + + client_devserver: + image: node + volumes: [ ./client:/client/ ] + working_dir: /client + command: [ "sh", "-c", "yarn && yarn dev --host 0.0.0.0" ] + expose: [ 5173 ] + networks: [ web ] + labels: + traefik.enable: true + traefik.http.routers.kalkutago_client.rule: 'Host(`kalkutago`) && !PathPrefix(`/api`)' + traefik.http.services.kalkutago_client.loadbalancer.server.port: 5173 + + proxy: + image: traefik + volumes: + - source: /var/run/docker.sock + target: /var/run/docker.sock + type: bind + - source: ./traefik.yaml + target: /traefik.yaml + type: bind + - source: ./traefik-config + target: /config + type: bind + ports: + - 80:80 + networks: [ web ] + +secrets: + postgres-password: + file: ./server/postgres.pw + +networks: + internal: + internal: true + web: + external: true diff --git a/docker-compose_prod.yml b/docker-compose_prod.yml new file mode 100644 index 0000000..cb57656 --- /dev/null +++ b/docker-compose_prod.yml @@ -0,0 +1,40 @@ +version: "3.5" + +services: + server: + # build: ./server + build: + context: ./server + dockerfile: Dockerfile.debug + networks: + - web + - internal + environment: + POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password + POSTGRES_USER: kalkutago + POSTGRES_DB: kalkutago + POSTGRES_HOST: database + secrets: [ postgres-password ] + depends_on: [ database ] + volumes: + - ./client/dist:/src/public:ro + database: + image: postgres + environment: + POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password + POSTGRES_USER: kalkutago + POSTGRES_DB: kalkutago + secrets: [ postgres-password ] + networks: [ internal ] + volumes: + - ./db.mount:/var/lib/postgresql/data + +secrets: + postgres-password: + file: ./server/postgres.pw + +networks: + internal: + internal: true + web: + external: true diff --git a/server/Cargo.lock b/server/Cargo.lock index 25a5894..59054fd 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1053,6 +1053,7 @@ dependencies = [ "derive_builder", "either", "femme", + "log", "rocket", "sea-orm", "sea-orm-migration", diff --git a/server/Cargo.toml b/server/Cargo.toml index 75a0af5..eddfb63 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] chrono = "0.4.26" femme = "2.2.1" +log = { version = "0.4.19", features = ["kv_unstable", "kv_unstable_serde"] } sea-orm-migration = "0.11.3" serde_json = "1.0.96" thiserror = "1.0.40" diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 4d60f42..fc35abf 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -9,24 +9,21 @@ pub(crate) mod update; use std::{ default::default, net::{IpAddr, Ipv4Addr}, - sync::Arc, }; use crate::error::Error; use rocket::{ fs::{FileServer, NamedFile}, - response::stream::{Event, EventStream}, + response::stream::EventStream, routes, Build, Config, Rocket, State, }; use sea_orm::DatabaseConnection; pub(crate) use error::ErrorResponder; -use tokio::sync::{ - broadcast::{self, error::RecvError, Receiver}, - RwLock, -}; +use tokio::sync::broadcast::{self, error::RecvError, Sender}; use self::{error::ApiResult, update::Update}; +use log::{as_debug, as_serde, debug, trace}; #[get("/status")] fn status() -> &'static str { @@ -34,14 +31,25 @@ fn status() -> &'static str { } #[get("/updates")] -async fn stream_updates(rx: &State>>>) -> EventStream![Event + '_] { - let rx: Arc>> = (rx as &Arc>>).clone(); +async fn stream_updates(tx: &State>) -> EventStream![] { + let mut rx = tx.subscribe(); EventStream![loop { - let mut rx = rx.write().await; - match rx.recv().await { - Ok(update) => yield update.to_event(), - Err(RecvError::Closed) => break, - Err(RecvError::Lagged(count)) => yield Update::lagged(count).to_event(), + let event = rx.recv().await; + match event { + Ok(update) => { + debug!(update = as_serde!(update); "sending update"); + let event = update.to_event(); + trace!(event = as_debug!(event); "this event"); + yield event; + } + Err(RecvError::Closed) => { + warn!("channel closed, ending update stream"); + break; + } + Err(RecvError::Lagged(count)) => { + warn!(count = count; "receiver lagged, instructing client to refresh"); + yield Update::lagged(count).to_event(); + } } }] } @@ -57,7 +65,7 @@ pub(crate) fn start_server(db: DatabaseConnection) -> Rocket { use groups::*; use ticks::*; use tracks::*; - let (tx, rx) = broadcast::channel::(8); + let (tx, _) = broadcast::channel::(8); let it = rocket::build() .configure(Config { address: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), @@ -66,7 +74,6 @@ pub(crate) fn start_server(db: DatabaseConnection) -> Rocket { .register("/", catchers![spa_index_redirect]) .manage(db) .manage(tx) - .manage(rx) .mount("/api/v1", routes![status, stream_updates]) .mount( "/api/v1/tracks", @@ -78,6 +85,8 @@ pub(crate) fn start_server(db: DatabaseConnection) -> Rocket { update_track, delete_track, ticked, + ticked_on_date, + clear_all_ticks, ], ) .mount( diff --git a/server/src/api/tracks.rs b/server/src/api/tracks.rs index ba3793d..a292ea7 100644 --- a/server/src/api/tracks.rs +++ b/server/src/api/tracks.rs @@ -56,11 +56,9 @@ pub(super) async fn insert_track( db: &State, track: Json, ) -> ApiResult> { - let mut track = track.0; + let track = track.0; let db = db as &DatabaseConnection; let mut model: tracks::ActiveModel = default(); - track["id"] = 0.into(); // dummy value. set_from_json doesn't use this value - // but for some reason requires it be set model.set_from_json(track).map_err(Error::from)?; Ok(Json(model.insert(db).await.map_err(Error::from)?)) } @@ -106,3 +104,49 @@ pub(super) async fn ticked( .map_err(Error::from)?; Ok(Json(tick)) } + +#[patch("//ticked?&&")] +pub(super) async fn ticked_on_date( + db: &State, + tx: &State>, + id: i32, + year: i32, + month: u32, + day: u32, +) -> ApiResult, Status>> { + let Some(date) = Date::from_ymd_opt(year, month, day) else { + return Ok(Right(Status::BadRequest)); + }; + let tick = ticks::ActiveModel::on(date, id); + let tick = tick + .insert(db as &DatabaseConnection) + .await + .map_err(Error::from)? + .to_owned(); + tx.send(Update::tick_added(tick.clone())) + .map_err(Error::from)?; + Ok(Left(Json(tick))) +} + +#[delete("//all-ticks")] +pub(super) async fn clear_all_ticks( + db: &State, + tx: &State>, + id: i32, +) -> ApiResult>>> { + let db = db as &DatabaseConnection; + let Some(track) = Tracks::find_by_id(id).one(db).await.map_err(Error::from)? else { + info!(track_id = id; "couldn't drop all ticks for track; track not found"); + return Ok(Left(Status::NotFound)); + }; + let ticks = track + .find_related(Ticks) + .all(db) + .await + .map_err(Error::from)?; + for tick in ticks.clone() { + tick.clone().delete(db).await.map_err(Error::from)?; + Update::tick_cancelled(tick).send(&tx)?; + } + Ok(Right(Json(ticks))) +} diff --git a/server/src/api/update.rs b/server/src/api/update.rs index 74f21e2..0b6adba 100644 --- a/server/src/api/update.rs +++ b/server/src/api/update.rs @@ -1,11 +1,13 @@ +use log::as_serde; use rocket::response::stream::Event; use serde::{Deserialize, Serialize}; use serde_json::json; +use tokio::sync::broadcast::Sender; -use crate::entities::ticks; +use crate::{entities::ticks, error::Result}; #[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) enum Update { +pub enum Update { TickChanged { kind: UpdateType, tick: ticks::Model, @@ -18,32 +20,35 @@ pub(crate) enum Update { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) enum UpdateType { +pub enum UpdateType { TickAdded, TickDropped, Error, } impl Update { - pub(crate) fn lagged(count: u64) -> Update { + pub fn lagged(count: u64) -> Update { Update::Lagged { kind: UpdateType::Error, count, } } - pub(crate) fn tick_added(tick: ticks::Model) -> Self { + + pub fn tick_added(tick: ticks::Model) -> Self { Self::TickChanged { kind: UpdateType::TickAdded, tick, } } - pub(crate) fn tick_cancelled(tick: ticks::Model) -> Self { + + pub fn tick_cancelled(tick: ticks::Model) -> Self { Self::TickChanged { kind: UpdateType::TickDropped, tick, } } - pub(crate) fn to_event(&self) -> Event { + + pub fn to_event(&self) -> Event { use Update::*; match self { TickChanged { kind, tick } => Event::json(tick).event(format!("{kind:?}")), @@ -53,4 +58,10 @@ impl Update { } } } + + pub fn send(self, tx: &Sender) -> Result<()> { + let count = tx.send(self.clone())?; + trace!(sent_to = count, update = as_serde!(self); "sent update to SSE channel"); + Ok(()) + } } diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index 03c66e7..b141c4f 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -45,7 +45,7 @@ fn get_env_var_or_file>(key: A) -> Option { /// Connect to the database using environment variables for configuration. /// Panics on any failure. -pub(crate) fn connection_url() -> String { +pub fn connection_url() -> String { let user = get_env_var_or_file("POSTGRES_USER").expect("$POSTGRES_USER"); let password = get_env_var_or_file("POSTGRES_PASSWORD").expect("$POSTGRES_PASSWORD"); let db = get_env_var_or_file("POSTGRES_DB").expect("$POSTGRES_DB"); diff --git a/server/src/entities/ticks.rs b/server/src/entities/ticks.rs index a593f78..41d4107 100644 --- a/server/src/entities/ticks.rs +++ b/server/src/entities/ticks.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; #[sea_orm(table_name = "ticks")] pub struct Model { #[sea_orm(primary_key)] + #[serde(skip_deserializing)] pub id: i32, pub track_id: Option, pub year: Option, @@ -62,4 +63,24 @@ impl ActiveModel { ..default() } } + pub(crate) fn on(date: Date, track_id: i32) -> Self { + use sea_orm::ActiveValue::Set; + let now = Utc::now(); + Self { + track_id: Set(Some(track_id)), + year: Set(Some(date.year())), + month: Set(date.month().try_into().ok()), + /* ^^^^^^^^^^^^^^^^^^^^^^^ + * I can't imagine a situation where this doesn't fit. This way, at + * least, if it fails, you just get a messed up database entry that + * doesn't do anything bad + */ + day: Set(date.day().try_into().ok()), + hour: Set(now.hour().try_into().ok()), + minute: Set(now.minute().try_into().ok()), + second: Set(now.second().try_into().ok()), + has_time_info: Set(Some(1)), + ..default() + } + } } diff --git a/server/src/entities/tracks.rs b/server/src/entities/tracks.rs index 0791db6..de4cef4 100644 --- a/server/src/entities/tracks.rs +++ b/server/src/entities/tracks.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; #[sea_orm(table_name = "tracks")] pub struct Model { #[sea_orm(primary_key)] + #[serde(skip_deserializing)] pub id: i32, pub name: String, pub description: String, diff --git a/server/src/error.rs b/server/src/error.rs index 2a0e389..fbf5c10 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -3,7 +3,7 @@ use std::string; use derive_builder::UninitializedFieldError; #[derive(Debug, thiserror::Error)] -pub(crate) enum Error { +pub enum Error { #[error(transparent)] Builder(#[from] UninitializedFieldError), #[error(transparent)] @@ -20,4 +20,4 @@ pub(crate) enum Error { ChannelSendError(#[from] tokio::sync::broadcast::error::SendError), } -pub(crate) type Result = std::result::Result; +pub type Result = std::result::Result; diff --git a/traefik-config/traefik_dynamic.yaml b/traefik-config/traefik_dynamic.yaml new file mode 100644 index 0000000..3556cab --- /dev/null +++ b/traefik-config/traefik_dynamic.yaml @@ -0,0 +1,5 @@ + routers: + api: + rule: Host(`traefik-monitor`) + entrypoints: [web] + service: api@internal diff --git a/traefik.yaml b/traefik.yaml new file mode 100644 index 0000000..f49b9c6 --- /dev/null +++ b/traefik.yaml @@ -0,0 +1,20 @@ +entrypoints: + web: + address: :80 + +api: + dashboard: true + +providers: + docker: + watch: true + network: web + exposedByDefault: false + file: + filename: /config/traefik_dynamic.yaml + +log: + level: INFO + format: json + +