diff --git a/Cargo.lock b/Cargo.lock index d32ea27..1416dbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.82" +version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", @@ -131,15 +131,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +checksum = "8f43644eed690f5374f1af436ecd6aea01cd201f6fbdf0178adaf6907afb2cec" dependencies = [ "async-trait", "axum-core", @@ -166,7 +166,7 @@ dependencies = [ "sync_wrapper 1.0.1", "tokio", "tokio-tungstenite", - "tower 0.4.13", + "tower", "tower-layer", "tower-service", "tracing", @@ -174,9 +174,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +checksum = "5e6b8ba012a258d63c9adfa28b9ddcf66149da6f986c5b5452e629d5ee64bf00" dependencies = [ "async-trait", "bytes", @@ -187,7 +187,7 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "sync_wrapper 0.1.2", + "sync_wrapper 1.0.1", "tower-layer", "tower-service", "tracing", @@ -292,9 +292,9 @@ checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "cc" -version = "1.1.21" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" +checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" dependencies = [ "jobserver", "libc", @@ -557,9 +557,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "miniz_oxide", @@ -714,7 +714,7 @@ dependencies = [ "tera", "tokio", "tokio-stream", - "tower 0.5.1", + "tower", "tower-http", "tracing", "tracing-subscriber", @@ -933,9 +933,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da62f120a8a37763efb0cf8fdf264b884c7b8b9ac8660b900c8661030c00e6ba" +checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" dependencies = [ "bytes", "futures-channel", @@ -946,7 +946,6 @@ dependencies = [ "pin-project-lite", "socket2", "tokio", - "tower 0.4.13", "tower-service", "tracing", ] @@ -1051,9 +1050,9 @@ checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" [[package]] name = "iri-string" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f755bd3806e06ad4f366f92639415d99a339a2c7ecf8c26ccea2097c11cb6" +checksum = "9c25163201be6ded9e686703e85532f8f852ea1f92ba625cb3c51f7fe6d07a4a" dependencies = [ "memchr", "serde", @@ -1111,9 +1110,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.158" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libm" @@ -1446,9 +1445,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c73c26c01b8c87956cea613c907c9d6ecffd8d18a2a5908e5de0adfaa185cea" +checksum = "fdbef9d1d47087a895abd220ed25eb4ad973a5e26f6a4367b038c25e28dfc2d9" dependencies = [ "memchr", "thiserror", @@ -1457,9 +1456,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664d22978e2815783adbdd2c588b455b1bd625299ce36b2a99881ac9627e6d8d" +checksum = "4d3a6e3394ec80feb3b6393c725571754c6188490265c61aaf260810d6b95aa0" dependencies = [ "pest", "pest_generator", @@ -1467,9 +1466,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d5487022d5d33f4c30d91c22afa240ce2a644e87fe08caad974d4eab6badbe" +checksum = "94429506bde1ca69d1b5601962c73f4172ab4726571a59ea95931218cb0e930e" dependencies = [ "pest", "pest_meta", @@ -1480,9 +1479,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0091754bbd0ea592c4deb3a122ce8ecbb0753b738aa82bc055fcc2eccc8d8174" +checksum = "ac8a071862e93690b6e34e9a5fb8e33ff3734473ac0245b27232222c4906a33f" dependencies = [ "once_cell", "pest", @@ -1527,26 +1526,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pin-project" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.14" @@ -1561,9 +1540,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "ppv-lite86" @@ -1647,9 +1626,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.4" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" dependencies = [ "bitflags 2.6.0", ] @@ -1800,9 +1779,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" [[package]] name = "rustls-webpki" @@ -1866,9 +1845,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" dependencies = [ "core-foundation-sys", "libc", @@ -2141,18 +2120,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", @@ -2247,9 +2226,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.21.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" dependencies = [ "futures-util", "log", @@ -2272,38 +2251,28 @@ dependencies = [ [[package]] name = "tower" -version = "0.4.13" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" dependencies = [ "futures-core", "futures-util", - "pin-project", "pin-project-lite", + "sync_wrapper 0.1.2", "tokio", "tower-layer", "tower-service", "tracing", ] -[[package]] -name = "tower" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" -dependencies = [ - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97" dependencies = [ "async-compression", - "base64 0.21.7", + "base64 0.22.1", "bitflags 2.6.0", "bytes", "futures-core", @@ -2320,7 +2289,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower 0.4.13", + "tower", "tower-layer", "tower-service", "tracing", @@ -2409,9 +2378,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" dependencies = [ "byteorder", "bytes", @@ -2422,7 +2391,6 @@ dependencies = [ "rand", "sha1", "thiserror", - "url", "utf-8", ] diff --git a/Cargo.toml b/Cargo.toml index 38f380a..39c663a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ tera = "1" tokio = { version = "1", features = ["full"] } tokio-stream = "0.1.15" tower = "0.5" -tower-http = { version = "0.5", features = ["full"] } +tower-http = { version = "0.6", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/Dockerfile b/Dockerfile index 0f04481..3efc4e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -FROM lukemathwalker/cargo-chef:latest-rust-bookworm AS chef +FROM rust:1.81 AS chef WORKDIR /app -RUN apt update && apt install lld clang -y + +RUN cargo install --locked cargo-chef sccache +ENV RUSTC_WRAPPER=sccache SCCACHE_DIR=/sccache FROM chef AS planner COPY . . @@ -9,20 +11,21 @@ RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=$SCCACHE_DIR,sharing=locked \ + cargo chef cook --release --recipe-path recipe.json COPY . . -RUN cargo build --release +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=$SCCACHE_DIR,sharing=locked \ + cargo build --release -FROM debian:bookworm-slim AS runtime +FROM gcr.io/distroless/cc-debian12 AS runtime WORKDIR /app -RUN apt-get update -y \ - && apt-get install -y --no-install-recommends openssl ca-certificates pkg-config \ - && apt-get autoremove -y \ - && apt-get clean -y \ - && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/gathering_surf gathering_surf COPY config config COPY assets assets COPY templates templates -ENV APP_ENVIRONMENT production +ENV APP_ENVIRONMENT=production ENTRYPOINT ["./gathering_surf"] diff --git a/assets/static/index.min.js b/assets/static/index.min.js index 5a13524..799916d 100644 --- a/assets/static/index.min.js +++ b/assets/static/index.min.js @@ -1 +1 @@ -!function(){"use strict";function e(e){if(null==e)throw"item is null or undefined";return e}function t(e){if(null==e)throw"item is null or undefined";if(e instanceof HTMLButtonElement)return e;throw"item is not a button"}function n(t,n){e(document.getElementById(t)).innerText=n}function a(e,t,n){document.getElementById(e)?.setAttribute(t,n)}function i(e,t){e instanceof Array?e.forEach((e=>{a(e,"style",t)})):a(e,"style",t)}function o(e){document.querySelectorAll(e).forEach((e=>e.remove()))}function l(e){document.getElementById(e)?.remove()}function r(e,t){document.getElementById(e)?.classList.remove(t)}function s(e){r(e,"hidden")}const c="#a8a29e",d={"#0bd674":"Good","#ffcd1e":"Fair to Good","#ff9500":"Poor","#f4496d":"Very Poor","#a8a29e":"Flat"};let u,g,p,m,f,w,v,y,h,x,b,_;function E(e){const a=new Date(e.starting_at).getHours();let E=new Date(e.starting_at).getHours();if(a>20){let t=24-a;const n=e.wave_height_labels.length-(e.wave_height_labels.length-t)%24;E=0,g=e.wave_height_labels.slice(t,n),u=e.quality.slice(t,n),p=e.wave_height.slice(t,n),m=e.wind_speed.slice(t,n),f=e.wind_direction.slice(t,n),w=e.wind_gust.slice(t,n),v=e.wave_period.slice(t,n),y=e.temperature.slice(t,n),h=e.dewpoint.slice(t,n),x=e.cloud_cover.slice(t,n),b=e.probability_of_precipitation.slice(t,n),_=e.probability_of_thunder.slice(t,n)}else{const t=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][new Date(e.starting_at).getDay()],n=[];for(let e=0;e=10&&e<12&&n.push(`${t} ${e} AM`),12===e&&n.push(`${t} ${e} PM`),e>12&&n.push(`${t} 0${e-12} PM`)):n.push(`${t} 12 AM`);const i=e.wave_height_labels.length+a-(e.wave_height_labels.length+a)%24;g=n.concat(e.wave_height_labels).slice(0,i),u=new Array(a).fill("#a8a29e").concat(e.quality).slice(0,i),p=new Array(a).fill(0).concat(e.wave_height).slice(0,i),m=new Array(a).fill(0).concat(e.wind_speed).slice(0,i),f=new Array(a).fill(0).concat(e.wind_direction).slice(0,i),w=new Array(a).fill(0).concat(e.wind_gust).slice(0,i),v=new Array(a).fill(0).concat(e.wave_period).slice(0,i),y=new Array(a).fill(0).concat(e.temperature).slice(0,i),h=new Array(a).fill(0).concat(e.dewpoint).slice(0,i),x=new Array(a).fill(0).concat(e.cloud_cover).slice(0,i),b=new Array(a).fill(0).concat(e.probability_of_precipitation).slice(0,i),_=new Array(a).fill(0).concat(e.probability_of_thunder).slice(0,i)}let A=(new Date).getHours();const I=document.getElementById("current-wave-height");""===I?.innerText&&(I.innerText=e.current_wave_height,"0"==e.current_wave_height&&(i("wave-quality",`background-color: ${c}`),n("wave-quality-text",d[c]),i("wave-quality-text",`color: ${c}`),o(".wave-quality-loader")),i("wave-icon",`transform: rotate(${e.current_wave_direction}deg);`),o(".wavey"));const q=document.getElementById("current-wave-period");""===q?.innerText&&(q.innerText=e.current_wave_period,l("wavey-period-loader")),n("legend-label",g[A]),n("legend-quality",d[u[A]]),n("legend-wave-height",p[A]),n("legend-wind-speed",m[A]),i("legend-wind-icon",`transform: rotate(${f[A]+180}deg);`),n("legend-wave-period",v[A]),n("legend-wind-gust",w[A]),n("forecast-as-of",`Updated ${e.as_of}`),n("forecast-as-of-2",`Updated ${e.as_of}`),o(".loader"),s("forecast"),s("wave-quality"),s("legend-container"),r("forecast-as-of-container","animate-pulse"),r("forecast-as-of-container-2","animate-pulse"),s("temperature-legend-container"),n("temperature-legend-label",g[A]),n("temperature-legend-temperature",y[A]),n("temperature-legend-dewpoint",h[A]),s("precipitation-legend-container"),n("precipitation-legend-label",g[A]),n("precipitation-legend-precipitation",b[A]),n("precipitation-legend-thunder",_[A]),n("precipitation-legend-cloud-cover",x[A]),i("legend",`background-color: ${u[A]};`),i(["legend-container","precipitation-legend-container","temperature-legend-container"],"margin-top: 1rem;");const $={id:"vert",defaults:{width:1,dash:[3,3]},afterInit:(e,t,n)=>{e.corsair={x:0,y:0}},afterDraw:(e,t,n)=>{if(e.tooltip?._active?.length){let t=e.tooltip._active[0].element.x,a=e.scales.y,i=e.ctx;i.save(),i.beginPath(),i.moveTo(t,a.top),i.lineTo(t,a.bottom),i.lineWidth=1,i.setLineDash(n.dash),i.strokeStyle="#fff",i.stroke(),i.restore()}if(A-E>C){const t=e.ctx,n=e.scales.x.getPixelForValue(A-E);t.save(),t.strokeStyle="#5b5b58",t.lineWidth=1,t.beginPath(),t.moveTo(n,0),t.lineTo(n,e.height),t.stroke(),t.fillStyle="#5b5b58",t.font="bold 1rem ui-sans-serif, system-ui, sans-serif",t.fillText("Now",A>14&&24===L?n-45:n+5,15),t.restore()}}};function B(e){let t;t=e>=g.length-a?g.length-a-1:e;T(t+(0===C?E:C))}function T(e){const t=u[e];i("legend",`background-color: ${t}`),n("legend-label",g[e]),n("legend-quality",d[t]),n("legend-wave-height",p[e]),n("legend-wind-speed",m[e]),i("legend-wind-icon",`transform: rotate(${f[e]+180}deg);`),n("legend-wave-period",v[e]),n("legend-wind-gust",w[e]),n("temperature-legend-label",g[e]),n("temperature-legend-temperature",y[e]),n("temperature-legend-dewpoint",h[e]),n("precipitation-legend-label",g[e]),n("precipitation-legend-precipitation",b[e]),n("precipitation-legend-thunder",_[e]),n("precipitation-legend-cloud-cover",x[e])}const k=(e,t,n)=>{const a=Chart.helpers.getRelativePosition(e,n),i=function(e){return 24===L||48===L?e&&e>0?e>=p.length-C?p.length-C-1:e:0:e&&e>0?e>=p.length?p.length-1:e:0}(n.scales.x.getValueForPixel(a.x));B(i)},M=(e,t)=>{const n=0===C?E:C;return 24===L?t%6==0?g[t+n]:null:48===L?t%8==0?g[t+n]:null:t%24==0?g[t+n]:null};const S=()=>window.innerWidth<768?24:window.innerWidth<1024?48:p.length;let L=S(),C=0,F=C+L;const H={size:24===L?14:18,weight:"semi-bold"},P=e=>({aspectRatio:1.75,onHover:k,borderRadius:5,maintainAspectRatio:!1,plugins:{legend:{display:!1},tooltip:{enabled:!1}},responsive:!0,interaction:{intersect:!1,axis:"x"},scales:{x:{ticks:{callback:M}},y:{grid:{color:"#1B1B1B"},beginAtZero:!0,max:e??10,ticks:{callback:function(e){return e%2!=0?"":e},font:H}}}}),D=new Chart(document.getElementById("forecast"),{type:"bar",plugins:[$],data:{labels:0===C?g.slice(E,F):g.slice(C,F),datasets:[{label:"wave height (feet)",data:0===C?p.slice(E,F):p.slice(C,F),pointStyle:!1,minBarLength:.1}]},options:{...P(void 0),elements:{bar:{backgroundColor:e=>(e=>0===C?u[e.dataIndex+E]:u[e.dataIndex+C])(e)||"#4ade80"}}}});function N(e){n("forecast-range",e)}function O(){if(0===C){let e=a>20&&(new Date).getHours()>20;N(24===L?e?"Tomorrow":"Today":e?`Tomorrow - ${g[C+25].split(" ")[0]}`:"Today - Tomorrow")}else if(24===L)N(g[C].split(" ")[0]);else{const e=g[F-1]??g[g.length-1];N(`${g[C].split(" ")[0]} - ${e.split(" ")[0]}`)}}function W(){const e=0===C?E:C;let t=g.slice(e,F);D.data.labels=t,D.data.datasets[0].data=p.slice(e,F),D.update(),U.data.labels=t,U.data.datasets[0].data=y.slice(e,F),U.update(),z.data.labels=t,z.data.datasets[0].data=b.slice(e,F),z.update()}O(),window.addEventListener("resize",(()=>{L=S(),C+L>g.length?(F=g.length,C=g.length-L):F=C+L,W(),B(0),0===C&&(t(document.getElementById("forecast-backward")).disabled=!0),C+L=p.length&&(t(document.getElementById("forecast-foreward")).disabled=!0),C>0&&(t(document.getElementById("forecast-backward")).disabled=!1),O()})),document.getElementById("forecast-backward")?.addEventListener("click",(()=>{C-L<0?(C=0,F=L):(F-=L,C-=L),W(),0===C?T(A):B(0),0===C&&(t(document.getElementById("forecast-backward")).disabled=!0),C+L{F+L>g.length?(F=g.length,C=F-L):(C+=L,F+=L),W(),B(0),C+L>=p.length&&(t(document.getElementById("forecast-foreward")).disabled=!0),C>0&&(t(document.getElementById("forecast-backward")).disabled=!1),O()}));const J=(e,t,n)=>({type:"bar",plugins:[$],data:{labels:0===C?g.slice(E,F):g.slice(C,F),datasets:[{label:n,data:e,pointStyle:!1,minBarLength:.1}]},options:{...P(100),elements:{bar:t?{backgroundColor:t,borderColor:t}:{}}}}),R=document.getElementById("temperature-forecast"),U=new Chart(R,J(0===C?y.slice(E,F):y.slice(C,F),"pink","F")),V=document.getElementById("precipitation-forecast"),z=new Chart(V,J(0===C?b.slice(E,F):b.slice(C,F),null,"%"));function G(e){const t=D.getElementsAtEventForMode(e,"nearest",{axis:"x",intersect:!1},!0);if(t[0]){const e=t[0].datasetIndex,n=t[0].index;U.tooltip.setActiveElements([{datasetIndex:e,index:n}]),U.setActiveElements([{datasetIndex:e,index:n}]),U.update(),z.tooltip.setActiveElements([{datasetIndex:e,index:n}]),z.setActiveElements([{datasetIndex:e,index:n}]),z.update()}else U.tooltip.setActiveElements([],{x:0,y:0}),U.setActiveElements([],{x:0,y:0}),U.update(),z.tooltip.setActiveElements([],{x:0,y:0}),z.setActiveElements([],{x:0,y:0}),z.update()}function j(e){const t=U.getElementsAtEventForMode(e,"nearest",{axis:"x",intersect:!1},!0);if(t[0]){const e=t[0].datasetIndex,n=t[0].index;D.tooltip.setActiveElements([{datasetIndex:e,index:n}]),D.setActiveElements([{datasetIndex:e,index:n}]),D.update(),z.tooltip.setActiveElements([{datasetIndex:e,index:n}]),z.setActiveElements([{datasetIndex:e,index:n}]),z.update()}else D.tooltip.setActiveElements([],{x:0,y:0}),D.setActiveElements([],{x:0,y:0}),D.update(),z.tooltip.setActiveElements([],{x:0,y:0}),z.setActiveElements([],{x:0,y:0}),z.update()}function Z(e){const t=z.getElementsAtEventForMode(e,"nearest",{axis:"x",intersect:!1},!0);if(t[0]){const e=t[0].datasetIndex,n=t[0].index;U.tooltip.setActiveElements([{datasetIndex:e,index:n}]),U.setActiveElements([{datasetIndex:e,index:n}]),U.update(),D.tooltip.setActiveElements([{datasetIndex:e,index:n}]),D.setActiveElements([{datasetIndex:e,index:n}]),D.update()}else U.tooltip.setActiveElements([],{x:0,y:0}),U.setActiveElements([],{x:0,y:0}),U.update(),D.tooltip.setActiveElements([],{x:0,y:0}),D.setActiveElements([],{x:0,y:0}),D.update()}D.canvas.onmousemove=G,U.canvas.onmousemove=j,z.canvas.onmousemove=Z,D.canvas.ontouchmove=G,U.canvas.ontouchmove=j,z.canvas.ontouchmove=Z}const A=e=>e.wind_speed===e.gusts||"0"===e.gusts?e.wind_speed:`${e.wind_speed}-${e.gusts}`;function I(e){o(".water-quality-loader"),s("current-water-quality"),n("current-water-quality-title","Closed for season"===e.water_quality?"-----":e.water_quality.toUpperCase()),n("current-water-quality-status-text",e.water_quality_text),"Advisory"===e.water_quality&&i("current-water-quality-title","color: #facc15;"),"Closed"===e.water_quality&&i("current-water-quality-title","color: #ef4444;")}function q(t){var n,a;l("forecast-container"),n="forecast-error",a=`\n
\n

\n Error loading forecast data - please refresh the page or try again later.\n

\n

${t}

\n
\n `,e(document.getElementById(n)).innerHTML=a}const $=e(document.querySelector("body"));new MutationObserver((t=>{for(const c of t)if(c.target instanceof HTMLElement&&("realtime-data"===c.target.id&&((a=JSON.parse(c.target.innerText)).wave_height&&(n("current-wave-height",a.wave_height),i("wave-icon",`transform: rotate(${a.wave_direction}deg);`),o(".wavey")),a.wave_period&&(n("current-wave-period",a.wave_period),l("wavey-period-loader")),(""===e(document.getElementById("wave-quality-text")).innerText||a.wave_height||parseFloat(e(document.getElementById("current-wave-height")).innerText??"0")>=1)&&(i("wave-quality",`background-color: ${a.quality_color};`),n("wave-quality-text",a.quality_text),i("wave-quality-text",`color: ${a.quality_color}`),o(".wave-quality-loader")),n("current-water-temp",a.water_temp),n("current-air-temp",a.air_temp),n("current-air-temp-2",a.air_temp),n("wind",A(a)),n("as-of",`As of ${a.as_of}`),i("wind-icon",`transform: rotate(${a.wind_direction+180}deg);`),o(".latest-loader"),s("wind-icon-container"),s("wave-icon-container"),r("as-of-container","animate-pulse"),s("wave-quality")),"water-quality-data"===c.target.id&&I(JSON.parse(c.target.innerText)),"forecast-data"===c.target.id))try{E(JSON.parse(c.target.innerText))}catch{console.log("failed to parse forecast, trying again."),setTimeout((()=>{if(c.target instanceof HTMLElement)try{E(JSON.parse(c.target.innerText))}catch(e){q(e)}}),100)}var a})).observe($,{attributes:!0,childList:!0,subtree:!0})}(); +!function(){"use strict";function e(e){if(null==e)throw"item is null or undefined";return e}function t(e){if(null==e)throw"item is null or undefined";if(e instanceof HTMLButtonElement)return e;throw"item is not a button"}function n(t,n){e(document.getElementById(t)).innerText=n}function a(e,t,n){document.getElementById(e)?.setAttribute(t,n)}function i(e,t){e instanceof Array?e.forEach((e=>{a(e,"style",t)})):a(e,"style",t)}function o(e){document.querySelectorAll(e).forEach((e=>e.remove()))}function l(e){document.getElementById(e)?.remove()}function r(e,t){document.getElementById(e)?.classList.remove(t)}function s(e){r(e,"hidden")}const c="#a8a29e",d={"#0bd674":"Good","#ffcd1e":"Fair to Good","#ff9500":"Poor","#f4496d":"Very Poor","#a8a29e":"Flat"};let u,g,p,m,f,w,v,y,h,x,b,_;function E(e){const a=new Date(e.starting_at).getHours();let E=new Date(e.starting_at).getHours();if(a>20){let t=24-a;const n=e.wave_height_labels.length-(e.wave_height_labels.length-t)%24;E=0,g=e.wave_height_labels.slice(t,n),u=e.quality.slice(t,n),p=e.wave_height.slice(t,n),m=e.wind_speed.slice(t,n),f=e.wind_direction.slice(t,n),w=e.wind_gust.slice(t,n),v=e.wave_period.slice(t,n),y=e.temperature.slice(t,n),h=e.dewpoint.slice(t,n),x=e.cloud_cover.slice(t,n),b=e.probability_of_precipitation.slice(t,n),_=e.probability_of_thunder.slice(t,n)}else{const t=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][new Date(e.starting_at).getDay()],n=[];for(let e=0;e=10&&e<12&&n.push(`${t} ${e} AM`),12===e&&n.push(`${t} ${e} PM`),e>12&&n.push(`${t} 0${e-12} PM`)):n.push(`${t} 12 AM`);const i=e.wave_height_labels.length+a-(e.wave_height_labels.length+a)%24;g=n.concat(e.wave_height_labels).slice(0,i),u=new Array(a).fill("#a8a29e").concat(e.quality).slice(0,i),p=new Array(a).fill(0).concat(e.wave_height).slice(0,i),m=new Array(a).fill(0).concat(e.wind_speed).slice(0,i),f=new Array(a).fill(0).concat(e.wind_direction).slice(0,i),w=new Array(a).fill(0).concat(e.wind_gust).slice(0,i),v=new Array(a).fill(0).concat(e.wave_period).slice(0,i),y=new Array(a).fill(0).concat(e.temperature).slice(0,i),h=new Array(a).fill(0).concat(e.dewpoint).slice(0,i),x=new Array(a).fill(0).concat(e.cloud_cover).slice(0,i),b=new Array(a).fill(0).concat(e.probability_of_precipitation).slice(0,i),_=new Array(a).fill(0).concat(e.probability_of_thunder).slice(0,i)}let A=(new Date).getHours();const I=document.getElementById("current-wave-height");""===I?.innerText&&(I.innerText=e.current_wave_height,"0"==e.current_wave_height&&(i("wave-quality",`background-color: ${c}`),n("wave-quality-text",d[c]),i("wave-quality-text",`color: ${c}`),o(".wave-quality-loader")),i("wave-icon",`transform: rotate(${e.current_wave_direction}deg);`),o(".wavey"));const q=document.getElementById("current-wave-period");""===q?.innerText&&(q.innerText=e.current_wave_period,l("wavey-period-loader")),n("legend-label",g[A]),n("legend-quality",d[u[A]]),n("legend-wave-height",p[A]),n("legend-wind-speed",m[A]),i("legend-wind-icon",`transform: rotate(${f[A]+180}deg);`),n("legend-wave-period",v[A]),n("legend-wind-gust",w[A]),n("forecast-as-of",`Updated ${e.as_of}`),n("forecast-as-of-2",`Updated ${e.as_of}`),o(".loader"),s("forecast"),s("wave-quality"),s("legend-container"),r("forecast-as-of-container","animate-pulse"),r("forecast-as-of-container-2","animate-pulse"),s("temperature-legend-container"),n("temperature-legend-label",g[A]),n("temperature-legend-temperature",y[A]),n("temperature-legend-dewpoint",h[A]),s("precipitation-legend-container"),n("precipitation-legend-label",g[A]),n("precipitation-legend-precipitation",b[A]),n("precipitation-legend-thunder",_[A]),n("precipitation-legend-cloud-cover",x[A]),i("legend",`background-color: ${u[A]};`),i(["legend-container","precipitation-legend-container","temperature-legend-container"],"margin-top: 1rem;");const T={id:"vert",defaults:{width:1,dash:[3,3]},afterInit:(e,t,n)=>{e.corsair={x:0,y:0}},afterDraw:(e,t,n)=>{if(e.tooltip?._active?.length){let t=e.tooltip._active[0].element.x,a=e.scales.y,i=e.ctx;i.save(),i.beginPath(),i.moveTo(t,a.top),i.lineTo(t,a.bottom),i.lineWidth=1,i.setLineDash(n.dash),i.strokeStyle="#fff",i.stroke(),i.restore()}if(A-E>C){const t=e.ctx,n=e.scales.x.getPixelForValue(A-E);t.save(),t.strokeStyle="#5b5b58",t.lineWidth=1,t.beginPath(),t.moveTo(n,0),t.lineTo(n,e.height),t.stroke(),t.fillStyle="#5b5b58",t.font="bold 1rem ui-sans-serif, system-ui, sans-serif",t.fillText("Now",A>14&&24===L?n-45:n+5,15),t.restore()}}};function $(e){let t;t=e>=g.length-a?g.length-a-1:e;B(t+(0===C?E:C))}function B(e){const t=u[e];i("legend",`background-color: ${t}`),n("legend-label",g[e]),n("legend-quality",d[t]),n("legend-wave-height",p[e]),n("legend-wind-speed",m[e]),i("legend-wind-icon",`transform: rotate(${f[e]+180}deg);`),n("legend-wave-period",v[e]),n("legend-wind-gust",w[e]),n("temperature-legend-label",g[e]),n("temperature-legend-temperature",y[e]),n("temperature-legend-dewpoint",h[e]),n("precipitation-legend-label",g[e]),n("precipitation-legend-precipitation",b[e]),n("precipitation-legend-thunder",_[e]),n("precipitation-legend-cloud-cover",x[e])}const k=(e,t,n)=>{const a=Chart.helpers.getRelativePosition(e,n),i=function(e){return 24===L||48===L?e&&e>0?e>=p.length-C?p.length-C-1:e:0:e&&e>0?e>=p.length?p.length-1:e:0}(n.scales.x.getValueForPixel(a.x));$(i)},M=(e,t)=>{const n=0===C?E:C;return 24===L?t%6==0?g[t+n]:null:48===L?t%8==0?g[t+n]:null:t%24==0?g[t+n]:null};const S=()=>window.innerWidth<768?24:window.innerWidth<1024?48:p.length;let L=S(),C=0,F=C+L;const H={size:24===L?14:18,weight:"semi-bold"},P=e=>({aspectRatio:1.75,onHover:k,borderRadius:5,maintainAspectRatio:!1,plugins:{legend:{display:!1},tooltip:{enabled:!1}},responsive:!0,interaction:{intersect:!1,axis:"x"},scales:{x:{ticks:{callback:M}},y:{grid:{color:"#1B1B1B"},beginAtZero:!0,max:e??10,ticks:{callback:function(e){return e%2!=0?"":e},font:H}}}}),D=new Chart(document.getElementById("forecast"),{type:"bar",plugins:[T],data:{labels:0===C?g.slice(E,F):g.slice(C,F),datasets:[{label:"wave height (feet)",data:0===C?p.slice(E,F):p.slice(C,F),pointStyle:!1,minBarLength:.1}]},options:{...P(void 0),elements:{bar:{backgroundColor:e=>(e=>0===C?u[e.dataIndex+E]:u[e.dataIndex+C])(e)||"#4ade80"}}}});function N(e){n("forecast-range",e)}function O(){if(0===C){let e=a>20&&(new Date).getHours()>20;N(24===L?e?"Tomorrow":"Today":e?`Tomorrow - ${g[C+25].split(" ")[0]}`:"Today - Tomorrow")}else if(24===L)N(g[C].split(" ")[0]);else{const e=g[F-1]??g[g.length-1];N(`${g[C].split(" ")[0]} - ${e.split(" ")[0]}`)}}function J(){const e=0===C?E:C;let t=g.slice(e,F);D.data.labels=t,D.data.datasets[0].data=p.slice(e,F),D.update(),U.data.labels=t,U.data.datasets[0].data=y.slice(e,F),U.update(),z.data.labels=t,z.data.datasets[0].data=b.slice(e,F),z.update()}O(),window.addEventListener("resize",(()=>{L=S(),C+L>g.length?(F=g.length,C=g.length-L):F=C+L,J(),$(0),0===C&&(t(document.getElementById("forecast-backward")).disabled=!0),C+L=p.length&&(t(document.getElementById("forecast-foreward")).disabled=!0),C>0&&(t(document.getElementById("forecast-backward")).disabled=!1),O()})),document.getElementById("forecast-backward")?.addEventListener("click",(()=>{C-L<0?(C=0,F=L):(F-=L,C-=L),J(),0===C?B(A):$(0),0===C&&(t(document.getElementById("forecast-backward")).disabled=!0),C+L{F+L>g.length?(F=g.length,C=F-L):(C+=L,F+=L),J(),$(0),C+L>=p.length&&(t(document.getElementById("forecast-foreward")).disabled=!0),C>0&&(t(document.getElementById("forecast-backward")).disabled=!1),O()}));const W=(e,t,n)=>({type:"bar",plugins:[T],data:{labels:0===C?g.slice(E,F):g.slice(C,F),datasets:[{label:n,data:e,pointStyle:!1,minBarLength:.1}]},options:{...P(100),elements:{bar:t?{backgroundColor:t,borderColor:t}:{}}}}),R=document.getElementById("temperature-forecast"),U=new Chart(R,W(0===C?y.slice(E,F):y.slice(C,F),"pink","F")),V=document.getElementById("precipitation-forecast"),z=new Chart(V,W(0===C?b.slice(E,F):b.slice(C,F),null,"%"));function G(e){const t=D.getElementsAtEventForMode(e,"nearest",{axis:"x",intersect:!1},!0);if(t[0]){const e=t[0].datasetIndex,n=t[0].index;U.tooltip.setActiveElements([{datasetIndex:e,index:n}]),U.setActiveElements([{datasetIndex:e,index:n}]),U.update(),z.tooltip.setActiveElements([{datasetIndex:e,index:n}]),z.setActiveElements([{datasetIndex:e,index:n}]),z.update()}else U.tooltip.setActiveElements([],{x:0,y:0}),U.setActiveElements([],{x:0,y:0}),U.update(),z.tooltip.setActiveElements([],{x:0,y:0}),z.setActiveElements([],{x:0,y:0}),z.update()}function j(e){const t=U.getElementsAtEventForMode(e,"nearest",{axis:"x",intersect:!1},!0);if(t[0]){const e=t[0].datasetIndex,n=t[0].index;D.tooltip.setActiveElements([{datasetIndex:e,index:n}]),D.setActiveElements([{datasetIndex:e,index:n}]),D.update(),z.tooltip.setActiveElements([{datasetIndex:e,index:n}]),z.setActiveElements([{datasetIndex:e,index:n}]),z.update()}else D.tooltip.setActiveElements([],{x:0,y:0}),D.setActiveElements([],{x:0,y:0}),D.update(),z.tooltip.setActiveElements([],{x:0,y:0}),z.setActiveElements([],{x:0,y:0}),z.update()}function Z(e){const t=z.getElementsAtEventForMode(e,"nearest",{axis:"x",intersect:!1},!0);if(t[0]){const e=t[0].datasetIndex,n=t[0].index;U.tooltip.setActiveElements([{datasetIndex:e,index:n}]),U.setActiveElements([{datasetIndex:e,index:n}]),U.update(),D.tooltip.setActiveElements([{datasetIndex:e,index:n}]),D.setActiveElements([{datasetIndex:e,index:n}]),D.update()}else U.tooltip.setActiveElements([],{x:0,y:0}),U.setActiveElements([],{x:0,y:0}),U.update(),D.tooltip.setActiveElements([],{x:0,y:0}),D.setActiveElements([],{x:0,y:0}),D.update()}D.canvas.onmousemove=G,U.canvas.onmousemove=j,z.canvas.onmousemove=Z,D.canvas.ontouchmove=G,U.canvas.ontouchmove=j,z.canvas.ontouchmove=Z}const A=e=>e.wind_speed===e.gusts||"0"===e.gusts?e.wind_speed:`${e.wind_speed}-${e.gusts}`;function I(e){o(".water-quality-loader"),s("current-water-quality"),n("current-water-quality-title","Closed for season"===e.water_quality?"-----":e.water_quality.toUpperCase()),n("current-water-quality-status-text",e.water_quality_text),"Advisory"===e.water_quality&&i("current-water-quality-title","color: #facc15;"),"Closed"===e.water_quality&&i("current-water-quality-title","color: #ef4444;")}function q(t){var n,a;l("forecast-container"),n="forecast-error",a=`\n
\n

\n Error loading forecast data - please refresh the page or try again later.\n

\n

${t}

\n
\n `,e(document.getElementById(n)).innerHTML=a}const T=e(document.querySelector("body"));new MutationObserver((t=>{for(const c of t)if(c.target instanceof HTMLElement&&("realtime-data"===c.target.id&&((a=JSON.parse(c.target.innerText)).wave_height&&(n("current-wave-height",a.wave_height),i("wave-icon",`transform: rotate(${a.wave_direction}deg);`),o(".wavey")),a.wave_period&&(n("current-wave-period",a.wave_period),l("wavey-period-loader")),(""===e(document.getElementById("wave-quality-text")).innerText||a.wave_height||parseFloat(e(document.getElementById("current-wave-height")).innerText??"0")>=1)&&(i("wave-quality",`background-color: ${a.quality_color};`),n("wave-quality-text",a.quality_text),i("wave-quality-text",`color: ${a.quality_color}`),o(".wave-quality-loader")),n("current-water-temp",a.water_temp),n("current-air-temp",a.air_temp),n("current-air-temp-2",a.air_temp),n("wind",A(a)),n("as-of",`As of ${a.as_of}`),i("wind-icon",`transform: rotate(${a.wind_direction+180}deg);`),o(".latest-loader"),s("wind-icon-container"),s("wave-icon-container"),r("as-of-container","animate-pulse"),s("wave-quality")),"water-quality-data"===c.target.id&&I(JSON.parse(c.target.innerText)),"forecast-data"===c.target.id))try{E(JSON.parse(c.target.innerText))}catch{setTimeout((()=>{if(c.target instanceof HTMLElement)try{E(JSON.parse(c.target.innerText))}catch(e){setTimeout((()=>{if(c.target instanceof HTMLElement)try{E(JSON.parse(c.target.innerText))}catch(e){q(e)}}),1e3)}}),400)}var a})).observe(T,{attributes:!0,childList:!0,subtree:!0})}(); diff --git a/client/index.js b/client/index.js index 1203165..f654ff6 100644 --- a/client/index.js +++ b/client/index.js @@ -30,16 +30,23 @@ const observerCallback = (mutationList) => { try { parseForecast(JSON.parse(mutation.target.innerText)); } catch { - console.log("failed to parse forecast, trying again."); setTimeout(() => { if (mutation.target instanceof HTMLElement) { try { parseForecast(JSON.parse(mutation.target.innerText)); } catch (e) { - forecastFailed(e); + setTimeout(() => { + if (mutation.target instanceof HTMLElement) { + try { + parseForecast(JSON.parse(mutation.target.innerText)); + } catch (e) { + forecastFailed(e); + } + } + }, 1_000); } } - }, 100); + }, 400); } } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d9a3cad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,5 @@ +services: + web: + image: gathering_surf:latest + ports: + - "8080:8080" diff --git a/justfile b/justfile index 5be78f6..2518e21 100644 --- a/justfile +++ b/justfile @@ -87,7 +87,7 @@ dev: update: #!/bin/bash cargo update - echo $'Dependencies updated!\n' + echo -e "Dependencies updated! \n" cargo clippy just test @@ -163,3 +163,14 @@ install: just install-rollup fi + +# Builds the docker image +docker-build: + docker build --tag gathering_surf --file Dockerfile . + +docker-deploy: + DOCKER_HOST="ssh://austin@raspberrypi.local" docker compose up -d + +# Transfers the docker image to the pi and runs the deploy script +deploy: + just docker-build && docker save gathering_surf | bzip2 | ssh austin@raspberrypi.local docker load && just docker-deploy diff --git a/src/routes/root.rs b/src/routes/root.rs index 6c41203..a9ec893 100644 --- a/src/routes/root.rs +++ b/src/routes/root.rs @@ -143,7 +143,8 @@ pub async fn root( .status(StatusCode::OK) .header("Content-Type", "text/html; charset=UTF-8") .header("X-Content-Type-Options", "nosniff") - .header("content-encoding", "") + .header("content-encoding", "none") + .header("cache-control", "no-transform") .body(body)?) } diff --git a/templates/base.html b/templates/base.html index 0ff01ca..c6ffa3f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -37,7 +37,7 @@ rel="stylesheet" /> - + @@ -66,7 +66,7 @@ @keyup.escape="showLiveFeed = false; showNav = false;" :class="{ 'overflow-hidden': showNav || showLiveFeed }" > - + {% block body %} {% endblock %} {% include "includes/footer.html" %} {% if live_reload %}