···11+# strawmelonservices
22+33+## Gleam management application
44+55+The Gleam bits in this repo are a manager around the services.
66+77+### `strawmelonservices/serverone`
88+99+... is the main part of this manager, and is supposed to run on `marpi-5-1`, the current primary server in my tailscale.
1010+1111+It contains a dashboard, showing podman containers and their statuses, as well as allowing to start the backup procedure, during which `strawmelonservices/ollies_bkup` is notified on the backup server, allowing it to pull the backup archives over websocket. The previously used rsync is replaced by this, as SSH packet handshaking seems to be a little resource heavy on the arm singleboard. The music library is still rsync'ed.
1212+1313+### `strawmelonservices/ollies_bkup`
1414+1515+Runs a websocket client which is stand by until notice, and allows the backup server to pull the backup instead of the main server pushing it.
···11+name = "strawmelonservices"
22+version = "1.0.0"
33+44+# Fill out these fields if you intend to generate HTML documentation or publish
55+# your project to the Hex package manager.
66+#
77+# description = ""
88+# licences = ["Apache-2.0"]
99+# repository = { type = "github", user = "", repo = "" }
1010+# links = [{ title = "Website", href = "" }]
1111+#
1212+# For a full reference of all the available options, you can have a look at
1313+# https://gleam.run/writing-gleam/gleam-toml/.
1414+1515+[dependencies]
1616+gleam_stdlib = ">= 0.44.0 and < 2.0.0"
1717+ewe = ">= 3.0.7 and < 4.0.0"
1818+collie = ">= 1.0.0 and < 2.0.0"
1919+lustre = ">= 5.6.0 and < 6.0.0"
2020+group_registry = ">= 1.0.0 and < 2.0.0"
2121+gleam_erlang = ">= 1.3.0 and < 2.0.0"
2222+gleam_http = ">= 4.3.0 and < 5.0.0"
2323+gleam_otp = ">= 1.2.0 and < 2.0.0"
2424+gleam_json = ">= 3.1.0 and < 4.0.0"
2525+2626+[dev_dependencies]
2727+gleeunit = ">= 1.0.0 and < 2.0.0"
+35
manifest.toml
···11+# This file was generated by Gleam
22+# You typically do not need to edit this file
33+44+packages = [
55+ { name = "collie", version = "1.0.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "logging", "websocks"], otp_app = "collie", source = "hex", outer_checksum = "5796F28859B0CF0AF7E7B409775C291995B432B48BA2E2CDCA0D4B018B8162B5" },
66+ { name = "compresso", version = "0.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_stdlib", "gleam_yielder", "logging"], otp_app = "compresso", source = "hex", outer_checksum = "8BE29A1EDA42F70826ED148EAE40C46BB3FC18E78FE472663DB01DD4A38172D4" },
77+ { name = "ewe", version = "3.0.7", build_tools = ["gleam"], requirements = ["compresso", "exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "logging", "websocks"], otp_app = "ewe", source = "hex", outer_checksum = "5679A3763B79376C0846B23E42C60091441524FF6A6B5DD022ACB2BCB3F35BEC" },
88+ { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
99+ { name = "gleam_crypto", version = "1.5.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "43A25BCE17834475AEA7FFC9408B6D0FC1D709A682BCAE5FEFC6D4192CF47718" },
1010+ { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
1111+ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
1212+ { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
1313+ { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
1414+ { name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "702F3BC2A14793906880B1078B19A6165F87323AEE8D0C4A34085846336FCAAE" },
1515+ { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
1616+ { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
1717+ { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" },
1818+ { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" },
1919+ { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
2020+ { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" },
2121+ { name = "lustre", version = "5.6.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "EE558CD4DB9F09FCC16417ADF0183A3C2DAC3E4B21ED3AC0CAE859792AB810CA" },
2222+ { name = "websocks", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "websocks", source = "hex", outer_checksum = "C70340E5B6C3390383ADA17029DCA6F8903863A7AD8CD8E1520EDCC4FE70D6FD" },
2323+]
2424+2525+[requirements]
2626+collie = { version = ">= 1.0.0 and < 2.0.0" }
2727+ewe = { version = ">= 3.0.7 and < 4.0.0" }
2828+gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" }
2929+gleam_http = { version = ">= 4.3.0 and < 5.0.0" }
3030+gleam_json = { version = ">= 3.1.0 and < 4.0.0" }
3131+gleam_otp = { version = ">= 1.2.0 and < 2.0.0" }
3232+gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
3333+gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
3434+group_registry = { version = ">= 1.0.0 and < 2.0.0" }
3535+lustre = { version = ">= 5.6.0 and < 6.0.0" }
···11+// IMPORTS ---------------------------------------------------------------------
22+33+import gleam/dict.{type Dict}
44+import gleam/dynamic/decode
55+import gleam/erlang/process
66+import gleam/int
77+import gleam/list
88+import group_registry.{type GroupRegistry}
99+import lustre.{type App}
1010+import lustre/attribute.{attribute}
1111+import lustre/effect.{type Effect}
1212+import lustre/element.{type Element}
1313+import lustre/element/html
1414+import lustre/element/svg
1515+import lustre/event
1616+import lustre/server_component
1717+1818+// MAIN ------------------------------------------------------------------------
1919+2020+pub fn component() -> App(GroupRegistry(SharedMessage), Model, Message) {
2121+ lustre.application(init, update, view)
2222+}
2323+2424+// MODEL -----------------------------------------------------------------------
2525+2626+pub type Color {
2727+ Blue
2828+ Red
2929+ Green
3030+ Yellow
3131+}
3232+3333+fn to_string(color: Color) -> String {
3434+ case color {
3535+ Red -> "red"
3636+ Blue -> "blue"
3737+ Green -> "green"
3838+ Yellow -> "yellow"
3939+ }
4040+}
4141+4242+pub type Model {
4343+ Model(
4444+ drawn_points: Dict(#(Int, Int), Color),
4545+ selected_color: Color,
4646+ registry: GroupRegistry(SharedMessage),
4747+ )
4848+}
4949+5050+fn init(registry: GroupRegistry(SharedMessage)) -> #(Model, Effect(Message)) {
5151+ let model = Model(drawn_points: dict.new(), selected_color: Red, registry:)
5252+5353+ #(model, subscribe(registry, AppReceivedSharedMessage))
5454+}
5555+5656+fn subscribe(
5757+ registry: GroupRegistry(topic),
5858+ on_message handle_message: fn(topic) -> message,
5959+) -> Effect(message) {
6060+ // Using the special `select` effect lets us return a `Selector` so that we
6161+ // can receive messages from processes outside of our server component runtime.
6262+ use _, _ <- server_component.select
6363+6464+ // Joining a topic in the registry returns a `Subject` we need to select on
6565+ // in order to subscribe to messages sent to that topic.
6666+ let subject = group_registry.join(registry, "dashboard", process.self())
6767+6868+ // We need to teach the server component runtime to listen for messages on
6969+ // this subject by returning a `Selector` that matches our apps `message` type.
7070+ let selector =
7171+ process.new_selector()
7272+ |> process.select_map(subject, handle_message)
7373+7474+ selector
7575+}
7676+7777+// UPDATE ----------------------------------------------------------------------
7878+7979+/// We have 2 kinds of messages:
8080+///
8181+/// - Messages that originate from this instance of the server-component
8282+/// - Messages that we receive from and send to the glubsub topic.
8383+///
8484+/// `SharedMessage` contains messages of the latter type.
8585+pub opaque type SharedMessage {
8686+ // Received or sent when any client wants to draw on the dashboard.
8787+ ClientDrewCircle(x: Int, y: Int, color: Color)
8888+ /// Received or sent when any client wants to clear the screen.
8989+ ClientClearedScreen
9090+}
9191+9292+/// `Message` is the usual message type in a Lustre app and contains all messages
9393+/// that a single instance of our server component can receive.
9494+pub opaque type Message {
9595+ /// We received some SharedMessage from the glubsub topic.
9696+ AppReceivedSharedMessage(message: SharedMessage)
9797+ /// The user wants to change their selected color;
9898+ /// This color can be different for every single client.
9999+ UserChangedColor(color: Color)
100100+ /// We have a way to get notified if any client wants to modify the dashboard
101101+ /// through the shared topic, but we still need a way for the user to tell us
102102+ /// that they want to in the first place!
103103+ UserDrewCircle(x: Int, y: Int, color: Color)
104104+ ///
105105+ UserClearedScreen
106106+}
107107+108108+fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
109109+ case message {
110110+ AppReceivedSharedMessage(ClientDrewCircle(x:, y:, color:)) -> {
111111+ // If any client wants to draw on the screen, we do that update locally
112112+ // to reflect that change.
113113+ //
114114+ // Note: This means that we duplicate this state in every single client!
115115+ let new_points =
116116+ model.drawn_points
117117+ |> dict.insert(#(x, y), color)
118118+119119+ #(Model(..model, drawn_points: new_points), effect.none())
120120+ }
121121+122122+ AppReceivedSharedMessage(ClientClearedScreen) -> {
123123+ let model = Model(..model, drawn_points: dict.new())
124124+125125+ #(model, effect.none())
126126+ }
127127+128128+ UserChangedColor(color:) -> {
129129+ #(Model(..model, selected_color: color), effect.none())
130130+ }
131131+132132+ UserDrewCircle(x:, y:, color:) -> {
133133+ // If a user wants to draw, instead of doing that directly we broadcast
134134+ // that intent as a `SharedMessage` over our topic.
135135+ // Later, we will receive that same message ourselves again, at which point
136136+ // we will update our dashboard.
137137+ #(model, broadcast(model.registry, ClientDrewCircle(x:, y:, color:)))
138138+ }
139139+140140+ UserClearedScreen -> {
141141+ #(model, broadcast(model.registry, ClientClearedScreen))
142142+ }
143143+ }
144144+}
145145+146146+fn broadcast(registry: GroupRegistry(message), message: message) -> Effect(any) {
147147+ use _ <- effect.from
148148+ use member <- list.each(group_registry.members(registry, "dashboard"))
149149+150150+ process.send(member, message)
151151+}
152152+153153+// VIEW ------------------------------------------------------------------------
154154+155155+fn view(model: Model) -> Element(Message) {
156156+ let on_mouse_move =
157157+ event.on("mousemove", {
158158+ use button <- decode.field("buttons", decode.int)
159159+ use client_x <- decode.field("clientX", decode.int)
160160+ use client_y <- decode.field("clientY", decode.int)
161161+162162+ case button {
163163+ 1 ->
164164+ decode.success(UserDrewCircle(
165165+ x: client_x,
166166+ y: client_y,
167167+ color: model.selected_color,
168168+ ))
169169+ _ -> decode.failure(UserDrewCircle(x: 0, y: 0, color: Red), "Message")
170170+ }
171171+ })
172172+ |> server_component.include(["buttons", "clientX", "clientY"])
173173+ |> event.throttle(5)
174174+175175+ element.fragment([
176176+ html.style([], {
177177+ "
178178+ #controls {
179179+ align-items: center;
180180+ background-color: white;
181181+ border-radius: 2rem;
182182+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
183183+ display: flex;
184184+ gap: 1rem;
185185+ left: 1rem;
186186+ padding: 1rem 1.5rem;
187187+ position: fixed;
188188+ bottom: 1rem;
189189+ z-index: 1;
190190+ width: max-content;
191191+192192+ .colour {
193193+ border: none;
194194+ width: 3rem;
195195+ height: 3rem;
196196+ border-radius: 50%;
197197+ display: inline-block;
198198+ }
199199+200200+ .colour.selected {
201201+ filter: darken(0.2);
202202+ }
203203+ }
204204+205205+ svg {
206206+ background-color: oklch(98.4% 0.003 247.858);
207207+ position: fixed;
208208+ top: 0;
209209+ left: 0;
210210+ width: 100vw;
211211+ height: 100vh;
212212+ }
213213+ "
214214+ }),
215215+ html.div([attribute.id("controls")], [
216216+ html.button(
217217+ [
218218+ attribute.class("colour"),
219219+ attribute.style("background-color", "red"),
220220+ event.on_click(UserChangedColor(Red)),
221221+ ],
222222+ [],
223223+ ),
224224+ html.button(
225225+ [
226226+ attribute.class("colour selected"),
227227+ attribute.style("background-color", "green"),
228228+ event.on_click(UserChangedColor(Green)),
229229+ ],
230230+ [],
231231+ ),
232232+ html.button(
233233+ [
234234+ attribute.class("colour"),
235235+ attribute.style("background-color", "blue"),
236236+ event.on_click(UserChangedColor(Blue)),
237237+ ],
238238+ [],
239239+ ),
240240+ html.button(
241241+ [
242242+ attribute.class("colour"),
243243+ attribute.style("background-color", "yellow"),
244244+ event.on_click(UserChangedColor(Yellow)),
245245+ ],
246246+ [],
247247+ ),
248248+ html.button([event.on_click(UserClearedScreen)], [html.text("Clear")]),
249249+ ]),
250250+ html.svg([on_mouse_move], {
251251+ use points, #(x, y), color <- dict.fold(model.drawn_points, [])
252252+ let point =
253253+ svg.circle([
254254+ attribute("cx", int.to_string(x)),
255255+ attribute("cy", int.to_string(y)),
256256+ attribute("r", "5"),
257257+ attribute("fill", to_string(color)),
258258+ ])
259259+260260+ [point, ..points]
261261+ }),
262262+ ])
263263+}
+13
test/strawmelonservices_test.gleam
···11+import gleeunit
22+33+pub fn main() -> Nil {
44+ gleeunit.main()
55+}
66+77+// gleeunit test functions end in `_test`
88+pub fn hello_world_test() {
99+ let name = "Joe"
1010+ let greeting = "Hello, " <> name <> "!"
1111+1212+ assert greeting == "Hello, Joe!"
1313+}