···3838# Apply patches to dependencies
3939RUN cd /build && patch -p1 < patches/mist-websocket-protocol.patch
40404141+# Compile the client code and output to server's static directory
4242+RUN cd /build/client \
4343+ && gleam add --dev lustre_dev_tools \
4444+ && gleam run -m lustre/dev build client --minify --outdir=/build/server/priv/static
4545+4146# Compile the server code
4247RUN cd /build/server \
4348 && gleam export erlang-shipment
+24
client/README.md
···11+# cache_example
22+33+[](https://hex.pm/packages/cache_example)
44+[](https://hexdocs.pm/cache_example/)
55+66+```sh
77+gleam add cache_example@1
88+```
99+```gleam
1010+import cache_example
1111+1212+pub fn main() -> Nil {
1313+ // TODO: An example of the project in use
1414+}
1515+```
1616+1717+Further documentation can be found at <https://hexdocs.pm/cache_example>.
1818+1919+## Development
2020+2121+```sh
2222+gleam run # Run the project
2323+gleam test # Run the tests
2424+```
···11+/// JavaScript FFI for date formatting
22+33+export function formatTimeLocal(isoTimestamp) {
44+ try {
55+ const date = new Date(isoTimestamp);
66+ // Format as HH:MM:SS in local timezone
77+ return date.toLocaleTimeString("en-US", {
88+ hour: "2-digit",
99+ minute: "2-digit",
1010+ second: "2-digit",
1111+ hour12: false,
1212+ });
1313+ } catch (_e) {
1414+ return isoTimestamp;
1515+ }
1616+}
1717+1818+export function formatDateTimeLocal(isoTimestamp) {
1919+ try {
2020+ const date = new Date(isoTimestamp);
2121+ // Format as full date and time in local timezone
2222+ return date.toLocaleString("en-US", {
2323+ year: "numeric",
2424+ month: "2-digit",
2525+ day: "2-digit",
2626+ hour: "2-digit",
2727+ minute: "2-digit",
2828+ second: "2-digit",
2929+ hour12: false,
3030+ });
3131+ } catch (_e) {
3232+ return isoTimestamp;
3333+ }
3434+}
+9
client/src/date_formatter.gleam
···11+/// Date formatting utilities via JavaScript FFI
22+33+/// Format an ISO8601 timestamp to local time (HH:MM:SS in user's timezone)
44+@external(javascript, "./date_formatter.ffi.mjs", "formatTimeLocal")
55+pub fn format_time_local(iso_timestamp: String) -> String
66+77+/// Format an ISO8601 timestamp to full local datetime
88+@external(javascript, "./date_formatter.ffi.mjs", "formatDateTimeLocal")
99+pub fn format_datetime_local(iso_timestamp: String) -> String
+62
client/src/file_upload.ffi.mjs
···11+/// JavaScript FFI for file uploads
22+33+import { Error, Ok } from "./gleam.mjs";
44+55+/**
66+ * Read a file from an input element and encode it as base64
77+ * This is designed to work with Lustre's effect system by handling async via callbacks
88+ * @param {string} fileInputId - The ID of the file input element
99+ * @param {function} dispatch - The dispatch function to call with the result
1010+ * @returns {void}
1111+ */
1212+export function readFileAsBase64(fileInputId, dispatch) {
1313+ console.log("[readFileAsBase64] Called with fileInputId:", fileInputId);
1414+ const input = document.getElementById(fileInputId);
1515+1616+ if (!input) {
1717+ console.log("[readFileAsBase64] File input not found");
1818+ dispatch(new Error("File input not found"));
1919+ return;
2020+ }
2121+2222+ console.log("[readFileAsBase64] Input element:", input);
2323+ console.log("[readFileAsBase64] Input files:", input.files);
2424+ const file = input.files?.[0];
2525+2626+ if (!file) {
2727+ console.log("[readFileAsBase64] No file selected");
2828+ dispatch(new Error("No file selected"));
2929+ return;
3030+ }
3131+3232+ console.log("[readFileAsBase64] Reading file:", file.name);
3333+3434+ const reader = new FileReader();
3535+3636+ reader.onload = (e) => {
3737+ try {
3838+ const base64 = e.target.result.split(",")[1]; // Remove data:... prefix
3939+ dispatch(new Ok(base64));
4040+ } catch (err) {
4141+ dispatch(new Error(`Failed to encode file: ${err.message}`));
4242+ }
4343+ };
4444+4545+ reader.onerror = () => {
4646+ dispatch(new Error("Failed to read file"));
4747+ };
4848+4949+ reader.readAsDataURL(file);
5050+}
5151+5252+/**
5353+ * Clear a file input element
5454+ * @param {string} fileInputId - The ID of the file input element
5555+ * @returns {void}
5656+ */
5757+export function clearFileInput(fileInputId) {
5858+ const input = document.getElementById(fileInputId);
5959+ if (input) {
6060+ input.value = '';
6161+ }
6262+}
+15
client/src/file_upload.gleam
···11+/// File upload utilities via JavaScript FFI
22+///
33+/// Provides base64 encoding for file uploads through GraphQL
44+55+/// Read a file and encode it as base64
66+/// This is async and uses a callback (dispatch function) to return the result
77+@external(javascript, "./file_upload.ffi.mjs", "readFileAsBase64")
88+pub fn read_file_as_base64(
99+ file_input_id: String,
1010+ dispatch: fn(Result(String, String)) -> Nil,
1111+) -> Nil
1212+1313+/// Clear a file input element
1414+@external(javascript, "./file_upload.ffi.mjs", "clearFileInput")
1515+pub fn clear_file_input(file_input_id: String) -> Nil
···11+/**
22+ * Navigate to an external URL (outside the SPA)
33+ */
44+export function navigateToExternal(url) {
55+ globalThis.location.href = url;
66+}
+5
client/src/number_formatter.ffi.mjs
···11+/// JavaScript FFI for number formatting
22+33+export function formatNumber(number) {
44+ return new Intl.NumberFormat('en-US').format(number);
55+}
+5
client/src/number_formatter.gleam
···11+/// Number formatting utilities via JavaScript FFI
22+33+/// Format a number with locale-specific thousands separators
44+@external(javascript, "./number_formatter.ffi.mjs", "formatNumber")
55+pub fn format_number(number: Int) -> String
···11+/// JavaScript FFI for quickslice_client
22+33+/**
44+ * Set a timeout to call a callback after a delay
55+ * @param {function} callback - The function to call
66+ * @param {number} milliseconds - The delay in milliseconds
77+ * @returns {void}
88+ */
99+export function doSetTimeout(callback, milliseconds) {
1010+ setTimeout(callback, milliseconds);
1111+}
+835
client/src/quickslice_client.gleam
···11+/// ```graphql
22+/// mutation TriggerBackfill {
33+/// triggerBackfill
44+/// }
55+/// ```
66+/// ```graphql
77+/// query GetCurrentSession {
88+/// currentSession {
99+/// did
1010+/// handle
1111+/// isAdmin
1212+/// }
1313+/// }
1414+/// ```
1515+import components/layout
1616+import file_upload
1717+import generated/queries
1818+import navigation
1919+import generated/queries/get_activity_buckets.{ONEDAY}
2020+import generated/queries/get_current_session
2121+import generated/queries/get_recent_activity
2222+import generated/queries/get_settings
2323+import generated/queries/get_statistics
2424+import generated/queries/reset_all
2525+import generated/queries/trigger_backfill
2626+import generated/queries/update_domain_authority
2727+import generated/queries/upload_lexicons
2828+import gleam/io
2929+import gleam/json.{type Json}
3030+import gleam/list
3131+import gleam/option.{None}
3232+import gleam/uri
3333+import lustre
3434+import lustre/attribute
3535+import lustre/effect.{type Effect}
3636+import lustre/element.{type Element}
3737+import lustre/element/html
3838+import modem
3939+import pages/home
4040+import pages/settings
4141+import squall_cache
4242+import squall/registry
4343+4444+pub fn main() {
4545+ let app = lustre.application(init, update, view)
4646+ let assert Ok(_) = lustre.start(app, "#app", Nil)
4747+}
4848+4949+// MODEL
5050+5151+pub type Route {
5252+ Home
5353+ Settings
5454+ Upload
5555+}
5656+5757+pub type AuthState {
5858+ NotAuthenticated
5959+ Authenticated(did: String, handle: String, is_admin: Bool)
6060+}
6161+6262+pub type Model {
6363+ Model(
6464+ cache: squall_cache.Cache,
6565+ registry: registry.Registry,
6666+ route: Route,
6767+ time_range: get_activity_buckets.TimeRange,
6868+ settings_page_model: settings.Model,
6969+ is_backfilling: Bool,
7070+ auth_state: AuthState,
7171+ )
7272+}
7373+7474+fn init(_flags) -> #(Model, Effect(Msg)) {
7575+ // Create cache
7676+ let cache = squall_cache.new("http://localhost:8000/admin/graphql")
7777+7878+ // Initialize registry with all extracted queries
7979+ let reg = queries.init_registry()
8080+8181+ // Parse the initial route from the current URL
8282+ let initial_route = case modem.initial_uri() {
8383+ Ok(uri) -> parse_route(uri)
8484+ Error(_) -> Home
8585+ }
8686+8787+ // Fetch current session first (needed for all routes)
8888+ let #(cache_with_session, _) =
8989+ squall_cache.lookup(
9090+ cache,
9191+ "GetCurrentSession",
9292+ json.object([]),
9393+ get_current_session.parse_get_current_session_response,
9494+ )
9595+9696+ // Trigger initial data fetches for the route
9797+ let #(initial_cache, data_effects) = case initial_route {
9898+ Home -> {
9999+ // GetStatistics query
100100+ let #(cache1, _) =
101101+ squall_cache.lookup(
102102+ cache_with_session,
103103+ "GetStatistics",
104104+ json.object([]),
105105+ get_statistics.parse_get_statistics_response,
106106+ )
107107+108108+ // GetSettings query (for configuration alerts)
109109+ let #(cache2, _) =
110110+ squall_cache.lookup(
111111+ cache1,
112112+ "GetSettings",
113113+ json.object([]),
114114+ get_settings.parse_get_settings_response,
115115+ )
116116+117117+ // GetActivityBuckets query
118118+ let #(cache3, _) =
119119+ squall_cache.lookup(
120120+ cache2,
121121+ "GetActivityBuckets",
122122+ json.object([#("range", json.string("ONE_DAY"))]),
123123+ get_activity_buckets.parse_get_activity_buckets_response,
124124+ )
125125+126126+ // GetRecentActivity query
127127+ let #(cache4, _) =
128128+ squall_cache.lookup(
129129+ cache3,
130130+ "GetRecentActivity",
131131+ json.object([#("hours", json.int(24))]),
132132+ get_recent_activity.parse_get_recent_activity_response,
133133+ )
134134+135135+ // Process all pending fetches
136136+ let #(final_cache, fx) =
137137+ squall_cache.process_pending(cache4, reg, HandleQueryResponse, fn() {
138138+ 0
139139+ })
140140+ #(final_cache, fx)
141141+ }
142142+ Settings -> {
143143+ // GetSettings query
144144+ let #(cache1, _) =
145145+ squall_cache.lookup(
146146+ cache_with_session,
147147+ "GetSettings",
148148+ json.object([]),
149149+ get_settings.parse_get_settings_response,
150150+ )
151151+152152+ // Process pending fetches
153153+ let #(final_cache, fx) =
154154+ squall_cache.process_pending(cache1, reg, HandleQueryResponse, fn() {
155155+ 0
156156+ })
157157+ #(final_cache, fx)
158158+ }
159159+ _ -> #(cache_with_session, [])
160160+ }
161161+162162+ // Combine modem effect with data fetching effects
163163+ let modem_effect = modem.init(on_url_change)
164164+ let combined_effects = effect.batch([modem_effect, ..data_effects])
165165+166166+ #(
167167+ Model(
168168+ cache: initial_cache,
169169+ registry: reg,
170170+ route: initial_route,
171171+ time_range: ONEDAY,
172172+ settings_page_model: settings.init(),
173173+ is_backfilling: False,
174174+ auth_state: NotAuthenticated,
175175+ ),
176176+ combined_effects,
177177+ )
178178+}
179179+180180+// UPDATE
181181+182182+pub type Msg {
183183+ HandleQueryResponse(String, Json, Result(String, String))
184184+ HandleOptimisticMutationSuccess(String, String)
185185+ HandleOptimisticMutationFailure(String, String)
186186+ OnRouteChange(Route)
187187+ HomePageMsg(home.Msg)
188188+ SettingsPageMsg(settings.Msg)
189189+ FileRead(Result(String, String))
190190+}
191191+192192+fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
193193+ case msg {
194194+ HandleOptimisticMutationSuccess(mutation_id, response_body) -> {
195195+ // Mutation succeeded - commit the optimistic update
196196+ let cache_after_commit = squall_cache.commit_optimistic(model.cache, mutation_id, response_body)
197197+198198+ let new_settings_model =
199199+ settings.set_alert(
200200+ model.settings_page_model,
201201+ "success",
202202+ "Domain authority updated successfully",
203203+ )
204204+205205+ #(Model(..model, cache: cache_after_commit, settings_page_model: new_settings_model), effect.none())
206206+ }
207207+208208+ HandleOptimisticMutationFailure(mutation_id, error_message) -> {
209209+ // Mutation failed - rollback the optimistic update
210210+ let cache_after_rollback = squall_cache.rollback_optimistic(model.cache, mutation_id)
211211+212212+ // Get the actual saved value from cache to reset the input field
213213+ let saved_domain_authority = case squall_cache.lookup(
214214+ cache_after_rollback,
215215+ "GetSettings",
216216+ json.object([]),
217217+ get_settings.parse_get_settings_response,
218218+ ) {
219219+ #(_, squall_cache.Data(data)) -> data.settings.domain_authority
220220+ _ -> model.settings_page_model.domain_authority_input
221221+ }
222222+223223+ let new_settings_model =
224224+ settings.Model(
225225+ ..model.settings_page_model,
226226+ domain_authority_input: saved_domain_authority,
227227+ alert: option.Some(#("error", "Error: " <> error_message)),
228228+ )
229229+230230+ #(Model(..model, cache: cache_after_rollback, settings_page_model: new_settings_model), effect.none())
231231+ }
232232+233233+ HandleQueryResponse(query_name, variables, Ok(response_body)) -> {
234234+ // Store response in cache
235235+ let cache_with_data =
236236+ squall_cache.store_query(
237237+ model.cache,
238238+ query_name,
239239+ variables,
240240+ response_body,
241241+ 0,
242242+ )
243243+244244+ // Process any new pending fetches
245245+ let #(final_cache, effects) =
246246+ squall_cache.process_pending(
247247+ cache_with_data,
248248+ model.registry,
249249+ HandleQueryResponse,
250250+ fn() { 0 },
251251+ )
252252+253253+ // Reset is_backfilling flag for TriggerBackfill mutation
254254+ let updated_is_backfilling = case query_name {
255255+ "TriggerBackfill" -> False
256256+ _ -> model.is_backfilling
257257+ }
258258+259259+ // Update auth state when GetCurrentSession response arrives
260260+ let new_auth_state = case query_name {
261261+ "GetCurrentSession" -> {
262262+ case get_current_session.parse_get_current_session_response(response_body) {
263263+ Ok(data) -> {
264264+ case data.current_session {
265265+ option.Some(session) ->
266266+ Authenticated(
267267+ did: session.did,
268268+ handle: session.handle,
269269+ is_admin: session.is_admin,
270270+ )
271271+ option.None -> NotAuthenticated
272272+ }
273273+ }
274274+ Error(_) -> NotAuthenticated
275275+ }
276276+ }
277277+ _ -> model.auth_state
278278+ }
279279+280280+ // Show success message for mutations and populate settings data
281281+ let new_settings_model = case query_name {
282282+ "UpdateDomainAuthority" ->
283283+ settings.set_alert(
284284+ model.settings_page_model,
285285+ "success",
286286+ "Domain authority updated successfully",
287287+ )
288288+ "UploadLexicons" -> {
289289+ // Clear the file input so the same file can be uploaded again
290290+ file_upload.clear_file_input("lexicon-file-input")
291291+ settings.set_alert(
292292+ model.settings_page_model,
293293+ "success",
294294+ "Lexicons uploaded successfully",
295295+ )
296296+ }
297297+ "ResetAll" -> {
298298+ // Clear the domain authority input when reset completes
299299+ let cleared_model = settings.Model(
300300+ ..model.settings_page_model,
301301+ domain_authority_input: "",
302302+ )
303303+ settings.set_alert(
304304+ cleared_model,
305305+ "success",
306306+ "All data has been reset",
307307+ )
308308+ }
309309+ "GetSettings" -> {
310310+ // Populate the input field with the loaded domain authority
311311+ case get_settings.parse_get_settings_response(response_body) {
312312+ Ok(data) ->
313313+ settings.Model(
314314+ ..model.settings_page_model,
315315+ domain_authority_input: data.settings.domain_authority,
316316+ )
317317+ Error(_) -> model.settings_page_model
318318+ }
319319+ }
320320+ _ -> model.settings_page_model
321321+ }
322322+323323+ // Invalidate queries after mutations that change data
324324+ let #(cache_after_mutation, mutation_effects) = case query_name {
325325+ "ResetAll" -> {
326326+ // Invalidate home page queries so they refetch when navigating home
327327+ let cache1 =
328328+ squall_cache.invalidate(
329329+ final_cache,
330330+ "GetStatistics",
331331+ json.object([]),
332332+ )
333333+ let cache2 =
334334+ squall_cache.invalidate(
335335+ cache1,
336336+ "GetActivityBuckets",
337337+ json.object([
338338+ #("range", json.string(get_activity_buckets.time_range_to_string(model.time_range))),
339339+ ]),
340340+ )
341341+ let cache3 =
342342+ squall_cache.invalidate(
343343+ cache2,
344344+ "GetRecentActivity",
345345+ json.object([#("hours", json.int(24))]),
346346+ )
347347+348348+ // Refetch settings to keep the settings page working
349349+ let #(cache_with_settings, _) =
350350+ squall_cache.lookup(
351351+ cache3,
352352+ "GetSettings",
353353+ json.object([]),
354354+ get_settings.parse_get_settings_response,
355355+ )
356356+357357+ let #(final_cache_reset, refetch_effects) =
358358+ squall_cache.process_pending(
359359+ cache_with_settings,
360360+ model.registry,
361361+ HandleQueryResponse,
362362+ fn() { 0 },
363363+ )
364364+365365+ #(final_cache_reset, refetch_effects)
366366+ }
367367+ "UploadLexicons" -> {
368368+ // Invalidate statistics since lexicon count changed
369369+ let cache_invalidated =
370370+ squall_cache.invalidate(
371371+ final_cache,
372372+ "GetStatistics",
373373+ json.object([]),
374374+ )
375375+ #(cache_invalidated, [])
376376+ }
377377+ _ -> #(final_cache, [])
378378+ }
379379+380380+ // Check if we need to redirect after session loads
381381+ let redirect_effect = case query_name {
382382+ "GetCurrentSession" -> {
383383+ // If we're on settings route but not admin, redirect to home
384384+ case model.route, new_auth_state {
385385+ Settings, NotAuthenticated -> [modem.push("/", option.None, option.None)]
386386+ Settings, Authenticated(_, _, False) -> [modem.push("/", option.None, option.None)]
387387+ _, _ -> []
388388+ }
389389+ }
390390+ _ -> []
391391+ }
392392+393393+ #(
394394+ Model(..model, cache: cache_after_mutation, settings_page_model: new_settings_model, is_backfilling: updated_is_backfilling, auth_state: new_auth_state),
395395+ effect.batch([effects, mutation_effects, redirect_effect] |> list.flatten),
396396+ )
397397+ }
398398+399399+ HandleQueryResponse(query_name, _variables, Error(err)) -> {
400400+ // Reset is_backfilling flag for TriggerBackfill mutation
401401+ let updated_is_backfilling = case query_name {
402402+ "TriggerBackfill" -> False
403403+ _ -> model.is_backfilling
404404+ }
405405+406406+ // Show error message for mutations
407407+ let new_settings_model = case query_name {
408408+ "UpdateDomainAuthority" | "UploadLexicons" | "ResetAll" | "TriggerBackfill" ->
409409+ settings.set_alert(
410410+ model.settings_page_model,
411411+ "error",
412412+ "Error: " <> err,
413413+ )
414414+ _ -> model.settings_page_model
415415+ }
416416+417417+ #(Model(..model, settings_page_model: new_settings_model, is_backfilling: updated_is_backfilling), effect.none())
418418+ }
419419+420420+ OnRouteChange(route) -> {
421421+ // Clear any alerts when navigating away from settings
422422+ let cleared_settings_model = case model.route {
423423+ Settings -> settings.clear_alert(model.settings_page_model)
424424+ _ -> model.settings_page_model
425425+ }
426426+427427+ // When route changes, fetch data for that route
428428+ case route {
429429+ Home -> {
430430+ // Fetch home page data
431431+ // GetStatistics query
432432+ let #(cache1, _) =
433433+ squall_cache.lookup(
434434+ model.cache,
435435+ "GetStatistics",
436436+ json.object([]),
437437+ get_statistics.parse_get_statistics_response,
438438+ )
439439+440440+ // GetSettings query (for configuration alerts)
441441+ let #(cache2, _) =
442442+ squall_cache.lookup(
443443+ cache1,
444444+ "GetSettings",
445445+ json.object([]),
446446+ get_settings.parse_get_settings_response,
447447+ )
448448+449449+ // GetActivityBuckets query
450450+ let #(cache3, _) =
451451+ squall_cache.lookup(
452452+ cache2,
453453+ "GetActivityBuckets",
454454+ json.object([
455455+ #("range", json.string(get_activity_buckets.time_range_to_string(model.time_range))),
456456+ ]),
457457+ get_activity_buckets.parse_get_activity_buckets_response,
458458+ )
459459+460460+ // GetRecentActivity query
461461+ let #(cache4, _) =
462462+ squall_cache.lookup(
463463+ cache3,
464464+ "GetRecentActivity",
465465+ json.object([#("hours", json.int(24))]),
466466+ get_recent_activity.parse_get_recent_activity_response,
467467+ )
468468+469469+ // Process all pending fetches
470470+ let #(final_cache, effects) =
471471+ squall_cache.process_pending(
472472+ cache4,
473473+ model.registry,
474474+ HandleQueryResponse,
475475+ fn() { 0 },
476476+ )
477477+478478+ #(Model(..model, route: route, cache: final_cache, settings_page_model: cleared_settings_model), effect.batch(effects))
479479+ }
480480+ Settings -> {
481481+ // Check if user is admin
482482+ let is_admin = case model.auth_state {
483483+ Authenticated(_, _, admin) -> admin
484484+ NotAuthenticated -> False
485485+ }
486486+487487+ case is_admin {
488488+ False -> {
489489+ // Non-admin trying to access settings - redirect to home
490490+ #(model, modem.push("/", option.None, option.None))
491491+ }
492492+ True -> {
493493+ // Fetch settings data
494494+ let #(cache_with_lookup, _) =
495495+ squall_cache.lookup(
496496+ model.cache,
497497+ "GetSettings",
498498+ json.object([]),
499499+ get_settings.parse_get_settings_response,
500500+ )
501501+502502+ let #(final_cache, effects) =
503503+ squall_cache.process_pending(
504504+ cache_with_lookup,
505505+ model.registry,
506506+ HandleQueryResponse,
507507+ fn() { 0 },
508508+ )
509509+510510+ #(Model(..model, route: route, cache: final_cache, settings_page_model: cleared_settings_model), effect.batch(effects))
511511+ }
512512+ }
513513+ }
514514+ _ -> #(Model(..model, route: route, settings_page_model: cleared_settings_model), effect.none())
515515+ }
516516+ }
517517+518518+ HomePageMsg(home_msg) -> {
519519+ case home_msg {
520520+ home.ChangeTimeRange(new_range) -> {
521521+ // Update time range and fetch new activity data
522522+ let variables =
523523+ json.object([
524524+ #("range", json.string(get_activity_buckets.time_range_to_string(new_range))),
525525+ ])
526526+527527+ let #(cache_with_lookup, _) =
528528+ squall_cache.lookup(
529529+ model.cache,
530530+ "GetActivityBuckets",
531531+ variables,
532532+ get_activity_buckets.parse_get_activity_buckets_response,
533533+ )
534534+535535+ let #(final_cache, effects) =
536536+ squall_cache.process_pending(
537537+ cache_with_lookup,
538538+ model.registry,
539539+ HandleQueryResponse,
540540+ fn() { 0 },
541541+ )
542542+543543+ #(Model(..model, cache: final_cache, time_range: new_range), effect.batch(effects))
544544+ }
545545+546546+ home.OpenGraphiQL -> {
547547+ // Navigate to external GraphiQL page
548548+ navigation.navigate_to_external("/graphiql")
549549+ #(model, effect.none())
550550+ }
551551+552552+ home.TriggerBackfill -> {
553553+ // Trigger backfill mutation
554554+ let variables = json.object([])
555555+556556+ // Invalidate any cached mutation result to ensure a fresh request
557557+ let cache_invalidated = squall_cache.invalidate(model.cache, "TriggerBackfill", variables)
558558+559559+ let #(cache_with_lookup, _) =
560560+ squall_cache.lookup(
561561+ cache_invalidated,
562562+ "TriggerBackfill",
563563+ variables,
564564+ trigger_backfill.parse_trigger_backfill_response,
565565+ )
566566+567567+ let #(final_cache, effects) =
568568+ squall_cache.process_pending(
569569+ cache_with_lookup,
570570+ model.registry,
571571+ HandleQueryResponse,
572572+ fn() { 0 },
573573+ )
574574+575575+ // Set is_backfilling to True while request is pending
576576+ #(
577577+ Model(..model, cache: final_cache, is_backfilling: True),
578578+ effect.batch(effects),
579579+ )
580580+ }
581581+ }
582582+ }
583583+584584+ SettingsPageMsg(settings_msg) -> {
585585+ case settings_msg {
586586+ settings.UpdateDomainAuthorityInput(value) -> {
587587+ // Clear alert when user starts typing
588588+ let new_settings_model =
589589+ settings.Model(
590590+ ..model.settings_page_model,
591591+ domain_authority_input: value,
592592+ alert: None,
593593+ )
594594+ #(Model(..model, settings_page_model: new_settings_model), effect.none())
595595+ }
596596+597597+ settings.SubmitDomainAuthority -> {
598598+ // Clear any existing alert
599599+ let cleared_settings_model =
600600+ settings.Model(..model.settings_page_model, alert: None)
601601+602602+ // Execute optimistic mutation
603603+ let variables =
604604+ json.object([
605605+ #("domainAuthority", json.string(model.settings_page_model.domain_authority_input)),
606606+ ])
607607+608608+ // Create optimistic entity - get current oauthClientId from cache
609609+ let current_oauth_client_id = case squall_cache.lookup(
610610+ model.cache,
611611+ "GetSettings",
612612+ json.object([]),
613613+ get_settings.parse_get_settings_response,
614614+ ) {
615615+ #(_, squall_cache.Data(data)) -> data.settings.oauth_client_id
616616+ _ -> None
617617+ }
618618+619619+ let optimistic_entity =
620620+ json.object([
621621+ #("id", json.string("Settings:singleton")),
622622+ #("domainAuthority", json.string(model.settings_page_model.domain_authority_input)),
623623+ #("oauthClientId", json.nullable(current_oauth_client_id, json.string)),
624624+ ])
625625+626626+ let #(updated_cache, _mutation_id, mutation_effect) =
627627+ squall_cache.execute_optimistic_mutation(
628628+ model.cache,
629629+ model.registry,
630630+ "UpdateDomainAuthority",
631631+ variables,
632632+ "Settings:singleton",
633633+ fn(_current) { optimistic_entity },
634634+ update_domain_authority.parse_update_domain_authority_response,
635635+ fn(mutation_id, result, response_body) {
636636+ case result {
637637+ Ok(_) -> HandleOptimisticMutationSuccess(mutation_id, response_body)
638638+ Error(err) -> HandleOptimisticMutationFailure(mutation_id, err)
639639+ }
640640+ },
641641+ )
642642+643643+ #(Model(..model, cache: updated_cache, settings_page_model: cleared_settings_model), mutation_effect)
644644+ }
645645+646646+ settings.SelectLexiconFile -> {
647647+ // File selection is handled by browser - we'll read the file on upload
648648+ #(model, effect.none())
649649+ }
650650+651651+ settings.UploadLexicons -> {
652652+ // Read the file and convert to base64
653653+ io.println("[UploadLexicons] Button clicked, creating file effect")
654654+ let file_effect =
655655+ effect.from(fn(dispatch) {
656656+ io.println("[UploadLexicons] Effect running, calling read_file_as_base64")
657657+ file_upload.read_file_as_base64("lexicon-file-input", fn(result) {
658658+ io.println("[UploadLexicons] Callback received result")
659659+ dispatch(FileRead(result))
660660+ })
661661+ })
662662+ #(model, file_effect)
663663+ }
664664+665665+ settings.UpdateResetConfirmation(value) -> {
666666+ // Clear alert when user starts typing
667667+ let new_settings_model =
668668+ settings.Model(
669669+ ..model.settings_page_model,
670670+ reset_confirmation: value,
671671+ alert: None,
672672+ )
673673+ #(Model(..model, settings_page_model: new_settings_model), effect.none())
674674+ }
675675+676676+ settings.SubmitReset -> {
677677+ // Execute ResetAll mutation
678678+ let variables =
679679+ json.object([
680680+ #("confirm", json.string(model.settings_page_model.reset_confirmation)),
681681+ ])
682682+683683+ // Invalidate any cached mutation result to ensure a fresh request
684684+ let cache_invalidated = squall_cache.invalidate(model.cache, "ResetAll", variables)
685685+686686+ let #(cache_with_lookup, _) =
687687+ squall_cache.lookup(
688688+ cache_invalidated,
689689+ "ResetAll",
690690+ variables,
691691+ reset_all.parse_reset_all_response,
692692+ )
693693+694694+ let #(final_cache, effects) =
695695+ squall_cache.process_pending(
696696+ cache_with_lookup,
697697+ model.registry,
698698+ HandleQueryResponse,
699699+ fn() { 0 },
700700+ )
701701+702702+ // Clear the confirmation field and alert after submission
703703+ let new_settings_model =
704704+ settings.Model(
705705+ ..model.settings_page_model,
706706+ reset_confirmation: "",
707707+ alert: None,
708708+ )
709709+710710+ #(
711711+ Model(..model, cache: final_cache, settings_page_model: new_settings_model),
712712+ effect.batch(effects),
713713+ )
714714+ }
715715+ }
716716+ }
717717+718718+ FileRead(Ok(base64_content)) -> {
719719+ // File was successfully read, now upload it
720720+ io.println("[FileRead] Successfully read file, uploading...")
721721+ let variables =
722722+ json.object([#("zipBase64", json.string(base64_content))])
723723+724724+ // Invalidate any cached mutation result to ensure a fresh request
725725+ let cache_invalidated = squall_cache.invalidate(model.cache, "UploadLexicons", variables)
726726+727727+ let #(cache_with_lookup, _) =
728728+ squall_cache.lookup(
729729+ cache_invalidated,
730730+ "UploadLexicons",
731731+ variables,
732732+ upload_lexicons.parse_upload_lexicons_response,
733733+ )
734734+735735+ let #(final_cache, effects) =
736736+ squall_cache.process_pending(
737737+ cache_with_lookup,
738738+ model.registry,
739739+ HandleQueryResponse,
740740+ fn() { 0 },
741741+ )
742742+743743+ // Clear the selected file
744744+ let new_settings_model =
745745+ settings.Model(..model.settings_page_model, selected_file: None)
746746+747747+ #(
748748+ Model(..model, cache: final_cache, settings_page_model: new_settings_model),
749749+ effect.batch(effects),
750750+ )
751751+ }
752752+753753+ FileRead(Error(err)) -> {
754754+ // Handle file read error
755755+ io.println("[FileRead] Error reading file: " <> err)
756756+ let new_settings_model =
757757+ settings.set_alert(model.settings_page_model, "error", err)
758758+ #(Model(..model, settings_page_model: new_settings_model), effect.none())
759759+ }
760760+ }
761761+}
762762+763763+// VIEW
764764+765765+fn view(model: Model) -> Element(Msg) {
766766+ // Convert AuthState to Option for layout
767767+ let auth_info = case model.auth_state {
768768+ NotAuthenticated -> None
769769+ Authenticated(_did, handle, is_admin) -> option.Some(#(handle, is_admin))
770770+ }
771771+772772+ html.div(
773773+ [attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")],
774774+ [
775775+ html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], [
776776+ layout.header(auth_info),
777777+ case model.route {
778778+ Home -> view_home(model)
779779+ Settings -> view_settings(model)
780780+ Upload -> view_upload(model)
781781+ },
782782+ ]),
783783+ ],
784784+ )
785785+}
786786+787787+fn view_home(model: Model) -> Element(Msg) {
788788+ let is_admin = case model.auth_state {
789789+ Authenticated(_, _, is_admin) -> is_admin
790790+ NotAuthenticated -> False
791791+ }
792792+793793+ element.map(
794794+ home.view(model.cache, model.time_range, model.is_backfilling, is_admin),
795795+ HomePageMsg,
796796+ )
797797+}
798798+799799+fn view_settings(model: Model) -> Element(Msg) {
800800+ let is_admin = case model.auth_state {
801801+ Authenticated(_, _, is_admin) -> is_admin
802802+ NotAuthenticated -> False
803803+ }
804804+805805+ element.map(
806806+ settings.view(model.cache, model.settings_page_model, is_admin),
807807+ SettingsPageMsg,
808808+ )
809809+}
810810+811811+fn view_upload(_model: Model) -> Element(Msg) {
812812+ html.div([], [
813813+ html.h1([attribute.class("text-xl font-bold text-zinc-100 mb-4")], [
814814+ html.text("Upload"),
815815+ ]),
816816+ html.p([attribute.class("text-zinc-400")], [
817817+ html.text("Upload and manage data"),
818818+ ]),
819819+ ])
820820+}
821821+822822+// ROUTING
823823+824824+fn on_url_change(uri: uri.Uri) -> Msg {
825825+ OnRouteChange(parse_route(uri))
826826+}
827827+828828+fn parse_route(uri: uri.Uri) -> Route {
829829+ case uri.path {
830830+ "/" -> Home
831831+ "/settings" -> Settings
832832+ "/upload" -> Upload
833833+ _ -> Home
834834+ }
835835+}
+13
client/test/cache_example_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+}
-1
server/gleam.toml
···2727gleam_hackney = ">= 1.0.0 and < 2.0.0"
2828sqlight = ">= 1.0.0 and < 2.0.0"
2929gleam_time = ">= 1.4.0 and < 2.0.0"
3030-lustre = ">= 5.0.0 and < 6.0.0"
3130simplifile = ">= 2.0.0 and < 3.0.0"
3231argv = ">= 1.0.0 and < 2.0.0"
3332jose = ">= 1.11.10 and < 2.0.0"
+1-3
server/manifest.toml
···3434 { name = "lexicon", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../lexicon" },
3535 { name = "lexicon_graphql", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "swell"], source = "local", path = "../lexicon_graphql" },
3636 { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
3737- { name = "lustre", version = "5.4.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "40E097BABCE65FB7C460C073078611F7F5802EB07E1A9BFB5C229F71B60F8E50" },
3837 { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
3938 { name = "metrics", version = "1.0.1", build_tools = ["rebar3"], requirements = [], otp_app = "metrics", source = "hex", outer_checksum = "69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16" },
4039 { name = "mimerl", version = "1.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144" },
···7271lexicon = { path = "../lexicon" }
7372lexicon_graphql = { path = "../lexicon_graphql" }
7473logging = { version = ">= 1.3.0 and < 2.0.0" }
7575-lustre = { version = ">= 5.0.0 and < 6.0.0" }
7674mist = { version = ">= 5.0.3 and < 6.0.0" }
7775simplifile = { version = ">= 2.0.0 and < 3.0.0" }
7876sqlight = { version = ">= 1.0.0 and < 2.0.0" }
7777+swell = { version = ">= 1.0.0 and < 2.0.0" }
7978thoas = { version = ">= 1.0.0 and < 2.0.0" }
8079wisp = { version = ">= 2.1.0 and < 3.0.0" }
8180wisp_flash = { version = ">= 2.0.0 and < 3.0.0" }
8282-swell = { version = ">= 1.0.0 and < 2.0.0" }
···4444 use formdata <- wisp.require_form(req)
45454646 // Get login hint from form
4747- let login_hint = case formdata.values {
4848- [#("loginHint", hint), ..] -> hint
4949- _ -> ""
4747+ let login_hint = case list.key_find(formdata.values, "login_hint") {
4848+ Ok(hint) -> hint
4949+ Error(_) -> ""
5050 }
51515252 wisp.log_info("OAuth: Authorization requested for: " <> login_hint)
+16-8
server/src/oauth/session.gleam
···11import gleam/bit_array
22import gleam/crypto
33import gleam/dynamic/decode
44+import gleam/http/cookie
55+import gleam/http/response
46import gleam/int
57import gleam/option.{type Option}
68import gleam/result
···210212 Ok(Nil)
211213}
212214213213-/// Set session cookie on response
215215+/// Set session cookie on response with SameSite=None for fetch with credentials
214216pub fn set_session_cookie(
215217 response: Response,
216218 req: Request,
217219 session_id: String,
218220) -> Response {
219219- wisp.set_cookie(
220220- response,
221221- req,
222222- session_cookie_name,
223223- session_id,
224224- wisp.Signed,
225225- 60 * 60 * 24 * 14,
221221+ // Sign the session ID the same way wisp does
222222+ let signed_value = wisp.sign_message(req, <<session_id:utf8>>, crypto.Sha512)
223223+224224+ // Create cookie attributes without SameSite restriction
225225+ let attributes = cookie.Attributes(
226226+ max_age: option.Some(60 * 60 * 24 * 14),
227227+ domain: option.None,
228228+ path: option.Some("/"),
229229+ secure: False, // False for localhost HTTP
230230+ http_only: True,
231231+ same_site: option.None, // No SameSite restriction for JavaScript fetch
226232 )
233233+234234+ response.set_cookie(response, session_cookie_name, signed_value, attributes)
227235}
228236229237/// Get session ID from request cookies