commit 66ba338b817b08f4b51751502e803fdf0b23b194 Author: Sam Date: Wed Jun 10 11:51:56 2026 -0500 init diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..41f7e1d --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": { + "version": "1.0.5", + "resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a", + "integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..2d8fb9f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} + } +} \ No newline at end of file diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83274f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/_build/ +/deps/ +erl_crash.dump +*.ez +.elixir_ls/ +priv/logs diff --git a/README.md b/README.md new file mode 100644 index 0000000..f22548c --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Beam Experiment + +The server is a@10.0.0.1. This is on a wireguard network. + +## .env +it retrieves environment variables from ../frm.so/.env + +## Logs +logs go to /root/beam/prod.log (runtime.exs) +requests go to ./priv/logs/requests.json + +## To Run + +dev: +``` +iex -S mix +``` + +prod: +``` +MIX_ENV=prod elixir --name a@10.0.0.1 --cookie shutupnigga --erl "-detached -kernel inet_dist_listen_min 9100 inet_dist_listen_max 9100" -S mix run --no-halt +``` + +if having problems: run without --detached. + +to kill ports: + +``` +lsof -ti:4000 | xargs kill +lsof -ti:9100 | xargs kill +``` + +The inet_dist_listen_min part of this is telling it to use port 9100 to listen for outside connections. Otherwise, it would use a random high port. This 9100 is stored in the + +# Install Packages +``` +mix deps.get +``` + +# Terminal Commands +``` +Node.list() +``` + +# Connect Remotely + +``` +iex --name console@10.0.0.2 --cookie shutupnigga --remsh a@10.0.0.1 +``` \ No newline at end of file diff --git a/admin_htmx.html b/admin_htmx.html new file mode 100644 index 0000000..53f601c --- /dev/null +++ b/admin_htmx.html @@ -0,0 +1,54 @@ + + + + + admin + + + + + + +
+ + + + diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..3d63992 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,46 @@ +# config/runtime.exs +import Config +import Dotenvy + +source!([ + "../frm.so/.env", + ".env.#{config_env()}", + System.get_env() +]) + +database_url = env!("DATABASE_URL", :string) +%URI{host: db_host, port: db_port, path: db_path, userinfo: db_userinfo} = URI.parse(database_url) +[db_user, db_password] = String.split(db_userinfo || "", ":", parts: 2) + +config :forum, Forum.DB, + hostname: db_host, + port: db_port || 5432, + username: db_user, + password: db_password, + database: String.trim_leading(db_path || "", "/"), + pool_size: env!("POOL_SIZE", :integer, 10) + +config :forum, :server, + port: env!("PORT", :integer, 4000), + network_port_base: env!("NETWORK_PORT_BASE", :integer, 4001) + +config :forum, :auth, + jwt_secret: env!("JWT_SECRET", :string) + +# at the bottom of config/runtime.exs +if config_env() == :prod do + config :logger, :default_handler, + config: [ + file: ~c"/root/beam/prod.log", + filesync_repeat_interval: 5_000, + max_no_bytes: 10_000_000, + max_no_files: 5, + compress_on_rotate: true + ] + + config :logger, :default_formatter, + format: "$time [$level] $message\n", + metadata: [:mfa] + + config :logger, level: :info +end diff --git a/lib/forum/admin.ex b/lib/forum/admin.ex new file mode 100644 index 0000000..89a10e8 --- /dev/null +++ b/lib/forum/admin.ex @@ -0,0 +1,126 @@ +defmodule Forum.Admin do + @moduledoc """ + HTML fragments served to the HTMX admin page (/admin_htmx). Two + panels (processes, modules) plus their row-only partials. + """ + + # ── processes panel ─────────────────────────────────────────────── + + def processes_panel do + """ +
+
+ + +
+ + + + + + +
pidnameinitial callmemory (KB)msgsstatus
+
+ """ + end + + def processes_rows(rows) do + rows + |> build_tree() + |> render_tree(0) + |> IO.iodata_to_binary() + end + + defp build_tree(rows) do + by_parent = Enum.group_by(rows, & &1["parent"]) + Map.get(by_parent, nil, []) |> Enum.map(&attach_children(&1, by_parent)) + end + + defp attach_children(node, by_parent) do + children = Map.get(by_parent, node["pid"], []) + Map.put(node, "children", Enum.map(children, &attach_children(&1, by_parent))) + end + + defp render_tree(nodes, depth) do + Enum.map(nodes, fn node -> + [process_row(node, depth) | render_tree(node["children"], depth + 1)] + end) + end + + defp process_row(node, depth) do + row_cls = if node["mine"], do: ~s| class="mine"|, else: "" + pad = 8 + depth * 16 + name = node["name"] + name_cls = if name, do: ~s| class="name"|, else: "" + + [ + ~s||, + ~s|#{h(node["pid"])}|, + ~s|#{h(name || "-")}|, + ~s|#{h(node["initial_call"])}|, + ~s|#{node["memory_kb"]}|, + ~s|#{node["msgs"]}|, + ~s|#{h(node["status"])}|, + "" + ] + end + + # ── modules panel ───────────────────────────────────────────────── + + def modules_panel do + """ +
+
+ + + +
+ + + + + +
moduleappsource
+
+ """ + end + + def modules_rows(rows) do + Enum.map_join(rows, "", &module_row/1) + end + + defp module_row(r) do + row_cls = if r["mine"], do: ~s| class="mine"|, else: "" + + ~s|| <> + ~s|#{h(r["module"])}| <> + ~s|#{h(r["app"] || "-")}| <> + ~s|#{h(r["source"] || "(no source)")}| <> + "" + end + + # ── helpers ─────────────────────────────────────────────────────── + + defp h(text) when is_binary(text) do + text + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\"", """) + |> String.replace("'", "'") + end + + defp h(other), do: other |> to_string() |> h() +end diff --git a/lib/forum/application.ex b/lib/forum/application.ex new file mode 100644 index 0000000..0f8e43a --- /dev/null +++ b/lib/forum/application.ex @@ -0,0 +1,72 @@ +# lib/forum/application.ex +defmodule Forum.Application do + use Application + + @impl true + def start(_type, _args) do + db_config = Application.fetch_env!(:forum, Forum.DB) + server_config = Application.fetch_env!(:forum, :server) + + children = + [ + {Postgrex, [name: Forum.DB] ++ db_config}, + + # Unique process names for forms, networks, public sites, and HTTP servers. + {Registry, keys: :unique, name: Forum.ProcessRegistry}, + + # WebSocket clients subscribe here for live forms.html updates. + {Registry, keys: :duplicate, name: Forum.FormsSubscriberRegistry}, + + Forum.LogStore, + Forum.Networks, + Forum.PublicSiteSupervisor, + + # Parses forms.html and supervises one QuickBEAM runtime per + # child element. Also serves Forum.Forms.html/0. + Forum.Forms, + + # Re-parses forms.html whenever the file changes on disk. + Forum.Forms.Watcher, + + # Main HTTP + WebSocket server. + http_server_child(:main, Forum.Router, server_config[:port]) + ] ++ network_site_servers(server_config) + + with {:ok, sup} <- Supervisor.start_link(children, strategy: :one_for_one, name: Forum.Supervisor) do + # DynamicSupervisor can't start children from its own init/1, so + # we kick off the initial parse + spawn here. + Forum.Forms.reload() + Forum.Networks.start_networks() + Forum.PublicSiteSupervisor.start_networks() + {:ok, sup} + end + end + + defp network_site_servers(server_config) do + base_port = Keyword.get(server_config, :network_port_base, 4001) + + Forum.Networks.network_slugs() + |> Enum.with_index() + |> Enum.map(fn {network, index} -> + http_server_child( + {:network, network}, + {Forum.PublicSiteRouter, network: network}, + base_port + index + ) + end) + end + + defp http_server_child(key, plug, port) do + Supervisor.child_spec( + {Bandit, + plug: plug, + port: port, + thousand_island_options: [ + supervisor_options: [ + name: {:via, Registry, {Forum.ProcessRegistry, {:http_server, key}}} + ] + ]}, + id: {:http_server, key, port} + ) + end +end diff --git a/lib/forum/assets.ex b/lib/forum/assets.ex new file mode 100644 index 0000000..84020d8 --- /dev/null +++ b/lib/forum/assets.ex @@ -0,0 +1,23 @@ +defmodule Forum.Assets do + @moduledoc false + + def path(relative_path) do + source_path = Path.expand(Path.join("priv", relative_path), File.cwd!()) + + if File.exists?(source_path) do + source_path + else + Application.app_dir(:forum, Path.join("priv", relative_path)) + end + end + + def paths(relative_glob) do + source_pattern = Path.expand(Path.join("priv", relative_glob), File.cwd!()) + release_pattern = Application.app_dir(:forum, Path.join("priv", relative_glob)) + + case Path.wildcard(source_pattern) do + [] -> Path.wildcard(release_pattern) + paths -> paths + end + end +end diff --git a/lib/forum/db.ex b/lib/forum/db.ex new file mode 100644 index 0000000..c192776 --- /dev/null +++ b/lib/forum/db.ex @@ -0,0 +1,44 @@ +defmodule Forum.DB do + @moduledoc """ + Thin wrappers around `Postgrex.query/3`. No ORM, just SQL. + The `Forum.DB` name was registered when Postgrex started in `Application`. + """ + + def query(sql, params \\ []) do + Postgrex.query(__MODULE__, sql, params) + end + + def query!(sql, params \\ []) do + Postgrex.query!(__MODULE__, sql, params) + end + + def list_messages(opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + + %Postgrex.Result{rows: rows, columns: cols} = + query!("SELECT id, text, inserted_at FROM messages ORDER BY id DESC LIMIT $1", [limit]) + + rows_to_maps(rows, cols) + end + + def insert_message(text) when is_binary(text) do + %Postgrex.Result{rows: [row], columns: cols} = + query!( + "INSERT INTO messages (text) VALUES ($1) RETURNING id, text, inserted_at", + [text] + ) + + [map] = rows_to_maps([row], cols) + map + end + + # Postgrex returns rows as lists of values + a separate list of column names. + # Zip them into maps so JSON encoding is straightforward. + defp rows_to_maps(rows, cols) do + Enum.map(rows, fn row -> + cols + |> Enum.zip(row) + |> Map.new() + end) + end +end diff --git a/lib/forum/forms.ex b/lib/forum/forms.ex new file mode 100644 index 0000000..ef8d4b3 --- /dev/null +++ b/lib/forum/forms.ex @@ -0,0 +1,300 @@ +defmodule Forum.Forms do + @moduledoc """ + Owns the parsed forms document and supervises one QuickBEAM runtime + per element. `DynamicSupervisor` so the tree can change at runtime. + + State (parsed Floki tree) lives in the ETS table `:forum_forms`, + owned by the supervisor process. Reads are direct ETS lookups — no + GenServer round-trip — and the table dies cleanly with the supervisor. + + Public lifecycle: + Forum.Forms.path/0 — path to the root forms.html + Forum.Forms.reload/0 — re-read priv/forms.html and network forms + Forum.Forms.set_html/1 — replace HTML directly and reconcile + Forum.Forms.add_form/2 — start one runtime ad-hoc + Forum.Forms.remove_form/2 — stop the runtime keyed by {class, id} + Forum.Forms.reconcile/0 — diff stored elements vs running runtimes + Forum.Forms.html/0 — rendered root HTML generated from the Floki tree + Forum.Forms.html/1 — rendered network HTML generated from that network tree + Forum.Forms.tree/0 — parsed Floki tree + Forum.Forms.elements/0 — descriptor maps for children + """ + use DynamicSupervisor + require Logger + + @table :forum_forms + + # ── boot ────────────────────────────────────────────────────────── + + def start_link(_opts) do + DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl true + def init(:ok) do + :ets.new(@table, [:named_table, :public, :set, read_concurrency: true]) + DynamicSupervisor.init(strategy: :one_for_one) + end + + # ── reads ───────────────────────────────────────────────────────── + + def html do + render_tree(root_tree()) + end + + def html(network_slug) when is_binary(network_slug) do + network_slug + |> network_tree() + |> render_tree() + end + + def tree do + case :ets.lookup(@table, :tree) do + [{:tree, tree}] -> tree + [] -> [] + end + end + + def root_tree do + case :ets.lookup(@table, :root_tree) do + [{:root_tree, tree}] -> tree + [] -> [] + end + end + + def network_tree(network_slug) do + case :ets.lookup(@table, {:network_tree, network_slug}) do + [{{:network_tree, ^network_slug}, tree}] -> tree + [] -> root_tree() + end + end + + def elements do + tree() + |> Floki.find("forms- > *") + |> Enum.map(&to_descriptor/1) + end + + def path do + Forum.Assets.path("forms.html") + end + + def network_paths do + "networks/*/index.html" + |> Forum.Assets.paths() + |> Enum.map(fn path -> {path |> Path.dirname() |> Path.basename(), path} end) + |> Enum.sort() + end + + # ── mutations ───────────────────────────────────────────────────── + + @doc "Re-read priv/forms.html and priv/networks/*/index.html, store them, reconcile runtimes." + def reload do + root_tree = path() |> File.read!() |> Floki.parse_fragment!() + + network_trees = + Enum.map(network_paths(), fn {slug, path} -> + {slug, path |> File.read!() |> Floki.parse_fragment!()} + end) + + all_elements = + [root_tree | Enum.map(network_trees, fn {_slug, tree} -> tree end)] + |> Enum.flat_map(&Floki.find(&1, "forms- > *")) + + :ets.insert(@table, [ + {:tree, wrap_forms(all_elements)}, + {:root_tree, root_tree} + | Enum.map(network_trees, fn {slug, tree} -> {{:network_tree, slug}, tree} end) + ]) + + reconcile() + end + + @doc "Replace the canonical HTML with `raw` and reconcile runtimes." + def set_html(raw) when is_binary(raw) do + tree = Floki.parse_fragment!(raw) + :ets.insert(@table, [{:tree, tree}, {:root_tree, tree}]) + reconcile() + end + + @doc "Start one supervised form runtime under `{class_name, id}`." + def add_form(class_name, data) when is_binary(class_name) and is_map(data) do + DynamicSupervisor.start_child(__MODULE__, child_spec_for(class_name, data)) + end + + @doc "Stop the form runtime registered under `{class_name, id}`." + def remove_form(class_name, id) do + case Registry.lookup(Forum.ProcessRegistry, {:form, {class_name, id}}) do + [{pid, _}] -> DynamicSupervisor.terminate_child(__MODULE__, pid) + [] -> {:error, :not_found} + end + end + + # ── reconciliation ──────────────────────────────────────────────── + + @doc """ + Diffs stored elements against the currently-running form runtimes: + stops runtimes for elements that disappeared, starts runtimes for + elements that appeared. Same `{class, id}` keeps its existing + runtime even if the element's data changed — call `remove_form/2 + + add_form/2` (or set new HTML containing a different id) to force a + refresh. + """ + def reconcile do + desired = + Map.new(elements(), fn el -> + class = pascal_case(el["tag"]) + data = Map.put_new(el["attrs"], "content", el["text"]) + id = Map.get(data, "id") || :erlang.unique_integer([:positive]) + {{class, id}, {class, data}} + end) + + current_keys = MapSet.new(current_form_keys()) + desired_keys = MapSet.new(Map.keys(desired)) + + for {class, id} <- MapSet.difference(current_keys, desired_keys) do + remove_form(class, id) + end + + for key <- MapSet.difference(desired_keys, current_keys) do + {class, data} = Map.fetch!(desired, key) + add_form(class, data) + end + + Logger.info("Forum.Forms: reconciled, target=#{map_size(desired)} runtimes") + :ok + end + + defp current_form_keys do + for {_id, pid, _type, _mods} <- DynamicSupervisor.which_children(__MODULE__), + is_pid(pid), + {:form, key} <- Registry.keys(Forum.ProcessRegistry, pid), + do: key + end + + # ── child spec ──────────────────────────────────────────────────── + + defp child_spec_for(class_name, data) do + id = Map.get(data, "id") || :erlang.unique_integer([:positive]) + name = {:via, Registry, {Forum.ProcessRegistry, {:form, {class_name, id}}}} + + {QuickBEAM, + name: name, + id: {class_name, id}, + define: %{"className" => class_name, "formData" => data}, + script: Forum.Assets.path("js/form_init.js")} + end + + # ── helpers ─────────────────────────────────────────────────────── + + defp wrap_forms(elements), do: [{"forms-", [], elements}] + + defp render_tree([]), do: "" + + defp render_tree(tree) do + tree + |> Floki.find("forms- > *") + |> Enum.map(&to_descriptor/1) + |> Enum.group_by(& &1["tag"]) + |> Enum.map_join("\n", fn {tag, descriptors} -> render_table(tag, descriptors) end) + end + + defp render_table(tag, descriptors) do + tag_name = String.trim_trailing(tag, "-") + columns = columns_for(descriptors) + header = Enum.map_join(columns, "\n", &render_header/1) + rows = Enum.map_join(descriptors, "\n", &render_row(&1, columns)) + + """ +
+

<#{escape_html(tag_name)}->

+
+ + + + #{header} + + + + #{rows} + +
+
+
+ """ + |> String.trim() + end + + defp render_header(column) do + " #{escape_html(column)}" + end + + defp render_row(descriptor, columns) do + attrs = descriptor["attrs"] + + cells = + Enum.map_join(columns, "\n", fn column -> + value = if column == "content", do: descriptor["text"], else: Map.get(attrs, column, "") + " #{escape_html(value)}" + end) + + """ + + #{cells} + + """ + |> String.trim_trailing() + end + + defp columns_for(descriptors) do + attr_columns = + descriptors + |> Enum.flat_map(fn descriptor -> Map.keys(descriptor["attrs"]) end) + |> Enum.uniq() + + content_columns = + if Enum.any?(descriptors, &(&1["text"] != "")), do: ["content"], else: [] + + sort_columns(attr_columns) ++ content_columns + end + + defp sort_columns(columns) do + preferred = ["id", "network-id", "name", "title", "email", "key"] + preferred_columns = Enum.filter(preferred, &(&1 in columns)) + other_columns = columns -- preferred + + preferred_columns ++ Enum.sort(other_columns) + end + + defp to_descriptor({tag, attrs, content}) do + text = + content + |> Enum.filter(&is_binary/1) + |> Enum.join("") + |> String.trim() + + %{"tag" => tag, "attrs" => Map.new(attrs), "text" => text} + end + + defp escape_attr(value) do + value + |> to_string() + |> Plug.HTML.html_escape_to_iodata() + |> IO.iodata_to_binary() + end + + defp escape_html(value) do + value + |> to_string() + |> Plug.HTML.html_escape_to_iodata() + |> IO.iodata_to_binary() + end + + defp pascal_case(tag) do + tag + |> String.trim_trailing("-") + |> String.split("-") + |> Enum.reject(&(&1 == "")) + |> Enum.map_join("", &String.capitalize/1) + end +end diff --git a/lib/forum/forms/watcher.ex b/lib/forum/forms/watcher.ex new file mode 100644 index 0000000..a5b7a88 --- /dev/null +++ b/lib/forum/forms/watcher.ex @@ -0,0 +1,108 @@ +defmodule Forum.Forms.Watcher do + @moduledoc """ + Watches the canonical forms.html file and reloads `Forum.Forms` when it changes. + + This intentionally uses a small polling loop instead of an external file watcher + dependency so it works the same way in dev, test, and releases. + """ + use GenServer + require Logger + + @poll_ms 500 + @debounce_ms 200 + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + paths = paths() + state = %{paths: paths, signature: signature(paths), debounce_ref: nil} + + schedule_poll() + {:ok, state} + end + + @impl true + def handle_info(:poll, state) do + paths = paths() + next_signature = signature(paths) + state = %{state | paths: paths} + + state = + if next_signature != state.signature do + schedule_reload(%{state | signature: next_signature}) + else + state + end + + schedule_poll() + {:noreply, state} + end + + def handle_info({:reload, signature}, %{signature: signature} = state) do + case reload_forms() do + :ok -> + Logger.info("Forum.Forms.Watcher: reloaded #{Enum.join(state.paths, ", ")}") + broadcast_reload() + + {:error, reason} -> + Logger.error("Forum.Forms.Watcher: reload failed: #{inspect(reason)}") + end + + {:noreply, %{state | debounce_ref: nil}} + end + + def handle_info({:reload, _stale_signature}, state) do + {:noreply, state} + end + + defp schedule_reload(%{debounce_ref: ref} = state) when is_reference(ref) do + Process.cancel_timer(ref) + schedule_reload(%{state | debounce_ref: nil}) + end + + defp schedule_reload(state) do + ref = Process.send_after(self(), {:reload, state.signature}, @debounce_ms) + %{state | debounce_ref: ref} + end + + defp schedule_poll do + Process.send_after(self(), :poll, @poll_ms) + end + + defp paths do + [Forum.Forms.path() | Enum.map(Forum.Forms.network_paths(), fn {_slug, path} -> path end)] + end + + defp signature(paths) do + Enum.map(paths, fn path -> + case File.read(path) do + {:ok, raw} -> {path, :ok, :erlang.phash2(raw), byte_size(raw)} + {:error, reason} -> {path, :error, reason} + end + end) + end + + defp reload_forms do + try do + Forum.Forms.reload() + rescue + error -> {:error, error} + catch + kind, reason -> {:error, {kind, reason}} + else + :ok -> :ok + other -> {:error, other} + end + end + + defp broadcast_reload do + html = Forum.Forms.html() + + for {pid, _value} <- Registry.lookup(Forum.FormsSubscriberRegistry, :forms) do + send(pid, {:forms_reloaded, html}) + end + end +end diff --git a/lib/forum/log_store.ex b/lib/forum/log_store.ex new file mode 100644 index 0000000..2f1c32f --- /dev/null +++ b/lib/forum/log_store.ex @@ -0,0 +1,61 @@ +defmodule Forum.LogStore do + @moduledoc """ + Persists request and hosting logs to a JSON file. + """ + use GenServer + + @max_entries 1_000 + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def add(entry) when is_map(entry) do + GenServer.cast(__MODULE__, {:add, entry}) + end + + def list(limit \\ 200) do + GenServer.call(__MODULE__, {:list, limit}) + end + + @impl true + def init(_opts) do + path = Path.expand("priv/logs/requests.json", File.cwd!()) + File.mkdir_p!(Path.dirname(path)) + + {:ok, %{path: path, entries: read_entries(path)}} + end + + @impl true + def handle_cast({:add, entry}, state) do + entry = + entry + |> Map.put_new("id", System.unique_integer([:positive, :monotonic])) + |> Map.put_new("time", DateTime.utc_now() |> DateTime.to_iso8601()) + + entries = [entry | state.entries] |> Enum.take(@max_entries) + write_entries!(state.path, entries) + + {:noreply, %{state | entries: entries}} + end + + @impl true + def handle_call({:list, limit}, _from, state) do + {:reply, Enum.take(state.entries, limit), state} + end + + defp read_entries(path) do + with {:ok, raw} <- File.read(path), + {:ok, entries} when is_list(entries) <- Jason.decode(raw) do + entries + else + _ -> [] + end + end + + defp write_entries!(path, entries) do + tmp_path = path <> ".tmp" + File.write!(tmp_path, Jason.encode!(entries, pretty: true)) + File.rename!(tmp_path, path) + end +end diff --git a/lib/forum/modules.ex b/lib/forum/modules.ex new file mode 100644 index 0000000..6266b90 --- /dev/null +++ b/lib/forum/modules.ex @@ -0,0 +1,44 @@ +defmodule Forum.Modules do + @moduledoc """ + Snapshot of every loaded Erlang/Elixir module with its OTP app and + source file. Powers the /modules endpoint. + """ + + @doc """ + Returns a list of maps, one per loaded module, sorted by module name. + + Each map has string keys (JSON-friendly): + "module" — module name, e.g. "Elixir.Forum.Router" + "app" — OTP app the module belongs to, e.g. ":forum", or nil + "source" — absolute path to the source file at compile time, or nil + "mine" — true if the module belongs to this app + """ + def list do + my_modules = MapSet.new(Application.spec(:forum, :modules) || []) + + :code.all_loaded() + |> Enum.map(fn {mod, _beam} -> + %{ + "module" => inspect(mod), + "app" => format_app(Application.get_application(mod)), + "source" => source_for(mod), + "mine" => MapSet.member?(my_modules, mod) + } + end) + |> Enum.sort_by(& &1["module"]) + end + + defp format_app(nil), do: nil + defp format_app(app) when is_atom(app), do: inspect(app) + + defp source_for(mod) do + case Keyword.get(mod.module_info(:compile), :source) do + nil -> nil + source when is_list(source) -> List.to_string(source) + source when is_binary(source) -> source + _ -> nil + end + rescue + _ -> nil + end +end diff --git a/lib/forum/network.ex b/lib/forum/network.ex new file mode 100644 index 0000000..d66154d --- /dev/null +++ b/lib/forum/network.ex @@ -0,0 +1,161 @@ +defmodule Forum.Network do + @moduledoc """ + A per-network process for serving the app's forms view. + + Requests to `/` use the root forms. Requests to `/` use the + matching network forms from `priv/networks//index.html`. + """ + use GenServer + + @default_hosts MapSet.new(["localhost", "127.0.0.1", "0.0.0.0", ""]) + + def child_spec(opts) do + slug = opts |> Keyword.fetch!(:slug) |> normalize_slug() + + %{ + id: {:network, slug}, + start: {__MODULE__, :start_link, [[slug: slug]]}, + restart: :permanent, + shutdown: 5_000, + type: :worker + } + end + + def start_link(opts) do + slug = Keyword.fetch!(opts, :slug) + GenServer.start_link(__MODULE__, slug, name: via(slug)) + end + + def normalize_slug(slug) when is_binary(slug) do + slug + |> String.downcase() + |> String.split(":", parts: 2) + |> hd() + end + + def serve(pid, request) do + GenServer.call(pid, {:serve, request}) + end + + @impl true + def init(slug) do + {:ok, %{slug: normalize_slug(slug), root: site_root(slug), started_at: DateTime.utc_now()}} + end + + @impl true + def handle_call({:serve, request}, _from, state) do + response = do_serve(state, request) + {:reply, response, state} + end + + defp do_serve(%{slug: slug} = state, request) do + path = Map.fetch!(request, :path) + + cond do + default_site?(slug) and path in ["", "/"] -> + desktop_response() + + network_site?(slug) and path in ["", "/"] -> + network_forms_response(slug) + + File.dir?(state.root) -> + static_response(state.root, path) + + true -> + %{ + status: 404, + content_type: "text/plain", + body: + "No network configured for #{slug}. Create files under #{Path.relative_to_cwd(state.root)}." + } + end + end + + defp desktop_response do + body = + Forum.Assets.path("ui/desktop.html") + |> File.read!() + |> String.replace("", Forum.Forms.html()) + + %{status: 200, content_type: "text/html", body: body} + end + + defp network_forms_response(slug) do + body = + Forum.Assets.path("ui/desktop.html") + |> File.read!() + |> String.replace("", Forum.Forms.html(slug)) + + %{status: 200, content_type: "text/html", body: body} + end + + defp static_response(root, path) do + file_path = resolve_path(root, path) + + cond do + file_path == :invalid -> + %{status: 403, content_type: "text/plain", body: "Forbidden"} + + File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) -> + send_file(Path.join(file_path, "index.html")) + + File.regular?(file_path) -> + send_file(file_path) + + true -> + %{status: 404, content_type: "text/plain", body: "Not found"} + end + end + + defp resolve_path(root, path) do + relative = + path + |> String.trim_leading("/") + |> URI.decode() + + candidate = Path.expand(relative, root) + rel_to_root = Path.relative_to(candidate, Path.expand(root)) + + if rel_to_root == ".." or String.starts_with?(rel_to_root, "../") do + :invalid + else + candidate + end + end + + defp send_file(path) do + %{status: 200, content_type: content_type(path), body: File.read!(path)} + end + + defp content_type(path) do + case Path.extname(path) do + ".html" -> "text/html" + ".css" -> "text/css" + ".js" -> "application/javascript" + ".json" -> "application/json" + ".svg" -> "image/svg+xml" + ".png" -> "image/png" + ".jpg" -> "image/jpeg" + ".jpeg" -> "image/jpeg" + ".webp" -> "image/webp" + ".gif" -> "image/gif" + ".ico" -> "image/x-icon" + ".txt" -> "text/plain" + _ -> "application/octet-stream" + end + end + + defp site_root(slug) do + source_path = Path.expand(Path.join(["priv", "networks", normalize_slug(slug)]), File.cwd!()) + + if File.exists?(source_path) do + source_path + else + Application.app_dir(:forum, Path.join(["priv", "networks", normalize_slug(slug)])) + end + end + + defp default_site?(slug), do: MapSet.member?(@default_hosts, slug) + defp network_site?(slug), do: slug in Forum.Networks.network_slugs() + defp via(slug), do: {:via, Registry, {Forum.ProcessRegistry, {:network, normalize_slug(slug)}}} +end diff --git a/lib/forum/network_supervisor.ex b/lib/forum/network_supervisor.ex new file mode 100644 index 0000000..dc382b4 --- /dev/null +++ b/lib/forum/network_supervisor.ex @@ -0,0 +1,56 @@ +defmodule Forum.Networks do + @moduledoc """ + Starts one network process for each forms network the app serves. + """ + use DynamicSupervisor + + def start_link(opts) do + DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + DynamicSupervisor.init(strategy: :one_for_one) + end + + def get_or_start(slug) when is_binary(slug) do + key = Forum.Network.normalize_slug(slug) + + case Registry.lookup(Forum.ProcessRegistry, {:network, key}) do + [{pid, _}] -> + {:ok, pid} + + [] -> + spec = {Forum.Network, slug: key} + + case DynamicSupervisor.start_child(__MODULE__, spec) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + other -> other + end + end + end + + def networks do + Forum.ProcessRegistry + |> Registry.select([ + {{{:network, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]} + ]) + end + + def start_networks do + for slug <- network_slugs() do + get_or_start(slug) + end + + :ok + end + + def network_slugs do + "networks/*" + |> Forum.Assets.paths() + |> Enum.filter(&File.dir?/1) + |> Enum.map(&Path.basename/1) + |> Enum.sort() + end +end diff --git a/lib/forum/processes.ex b/lib/forum/processes.ex new file mode 100644 index 0000000..c6d4427 --- /dev/null +++ b/lib/forum/processes.ex @@ -0,0 +1,175 @@ +defmodule Forum.Processes do + @moduledoc """ + Snapshot of every BEAM process for the /processes endpoint. Pure data + function — the router serves a shell HTML and the WS handler returns + the rows produced here. + """ + + @doc """ + Returns a list of maps, one per live process, sorted by memory desc. + + Each map has string keys (JSON-friendly): + "pid" — inspected PID string, e.g. "#PID<0.123.0>" + "parent" — inspected PID string of immediate parent (from + $ancestors), or nil for roots / non-proc_lib processes + "name" — registered name string, or nil + "initial_call" — real $initial_call MFA, formatted as "Mod.fun/arity" + "memory_kb" — process memory in KB + "tree_memory_kb" — process memory plus all descendant process memory in KB + "msgs" — message queue length + "status" — :running | :runnable | :waiting | :suspended | :exiting + "mine" — true if the process is under Forum.Supervisor's tree + """ + # Registries to consult for "via" names — processes registered as + # {:via, Registry, {Foo, key}} don't show up under :registered_name, + # so we look them up by pid here and use the key as the display name. + @registries [ + Forum.ProcessRegistry, + Forum.FormsSubscriberRegistry + ] + + def list do + my_pids = my_pids() + + Process.list() + |> Enum.map(&info_for(&1, my_pids)) + |> Enum.reject(&is_nil/1) + |> add_tree_memory() + |> Enum.sort_by(& &1["memory_kb"], :desc) + end + + defp add_tree_memory(rows) do + by_parent = Enum.group_by(rows, & &1["parent"]) + {totals, _memo} = + Enum.map_reduce(rows, %{}, fn row, memo -> + {total, memo} = tree_memory(row, by_parent, memo) + {{row["pid"], total}, memo} + end) + + totals = Map.new(totals) + + Enum.map(rows, fn row -> + Map.put(row, "tree_memory_kb", Map.fetch!(totals, row["pid"])) + end) + end + + defp tree_memory(row, by_parent, memo) do + pid = row["pid"] + + case memo do + %{^pid => total} -> + {total, memo} + + %{} -> + children = Map.get(by_parent, pid, []) + + {children_total, memo} = + Enum.reduce(children, {0, memo}, fn child, {sum, memo} -> + {total, memo} = tree_memory(child, by_parent, memo) + {sum + total, memo} + end) + + total = row["memory_kb"] + children_total + {total, Map.put(memo, pid, total)} + end + end + + defp info_for(pid, my_pids) do + case Process.info(pid, [ + :registered_name, + :initial_call, + :memory, + :message_queue_len, + :status, + :dictionary + ]) do + nil -> + nil + + info -> + dict = info[:dictionary] || [] + real_call = Keyword.get(dict, :"$initial_call") || info[:initial_call] + ancestors = Keyword.get(dict, :"$ancestors", []) + + name = + format_name(info[:registered_name]) || lookup_registry_name(pid) + + %{ + "pid" => inspect(pid), + "parent" => parent_string(ancestors), + "name" => name, + "initial_call" => format_mfa(real_call), + "memory_kb" => div(info[:memory] || 0, 1024), + "msgs" => info[:message_queue_len] || 0, + "status" => info[:status], + "mine" => MapSet.member?(my_pids, pid) + } + end + end + + defp lookup_registry_name(pid) do + @registries + |> Enum.flat_map(fn registry -> + try do + Registry.keys(registry, pid) |> Enum.map(&format_registry_key/1) + rescue + # Registry not started yet (boot race) — skip silently. + ArgumentError -> [] + end + end) + |> case do + [] -> nil + names -> Enum.join(names, ", ") + end + end + + # {"Task", "1"} -> "Task#1". Falls back to inspect for unknown shapes. + defp format_registry_key({:form, {class, id}}) when is_binary(class), do: "#{class}##{id}" + defp format_registry_key({:network, slug}), do: "network:#{slug}" + defp format_registry_key({:public_site, network}), do: "public-site:#{network}" + defp format_registry_key({:http_server, :main}), do: "http-server:main" + defp format_registry_key({:http_server, {:network, network}}), do: "http-server:#{network}" + defp format_registry_key(:forms), do: "forms-subscriber:forms" + defp format_registry_key(other), do: inspect(other) + + # The first entry in $ancestors is the immediate parent. proc_lib + # stores PIDs directly; for processes registered by name it can also + # be the registered name (atom), which we resolve to its current PID. + defp parent_string([pid | _]) when is_pid(pid), do: inspect(pid) + + defp parent_string([name | _]) when is_atom(name) do + case Process.whereis(name) do + nil -> nil + pid -> inspect(pid) + end + end + + defp parent_string(_), do: nil + + defp my_pids do + case Process.whereis(Forum.Supervisor) do + nil -> MapSet.new() + sup -> MapSet.new([sup | all_descendants(sup)]) + end + end + + defp all_descendants(sup) do + Supervisor.which_children(sup) + |> Enum.flat_map(fn {_id, pid, type, _modules} -> + cond do + not is_pid(pid) -> [] + type == :supervisor -> [pid | all_descendants(pid)] + true -> [pid] + end + end) + end + + defp format_name([]), do: nil + defp format_name(name) when is_atom(name), do: inspect(name) + defp format_name(_), do: nil + + defp format_mfa({m, f, a}) when is_atom(m) and is_atom(f) and is_integer(a), + do: "#{inspect(m)}.#{f}/#{a}" + + defp format_mfa(_), do: "-" +end diff --git a/lib/forum/public_site.ex b/lib/forum/public_site.ex new file mode 100644 index 0000000..f6d65d2 --- /dev/null +++ b/lib/forum/public_site.ex @@ -0,0 +1,182 @@ +defmodule Forum.PublicSite do + @moduledoc """ + A per-network process for serving public static websites on separate ports. + """ + use GenServer + + def child_spec(opts) do + network = opts |> Keyword.fetch!(:network) |> normalize_network() + + %{ + id: {:public_site, network}, + start: {__MODULE__, :start_link, [[network: network]]}, + restart: :permanent, + shutdown: 5_000, + type: :worker + } + end + + def start_link(opts) do + network = Keyword.fetch!(opts, :network) + GenServer.start_link(__MODULE__, network, name: via(network)) + end + + def normalize_network(network) when is_binary(network) do + network + |> String.downcase() + |> String.split(":", parts: 2) + |> hd() + end + + def serve(pid, request) do + GenServer.call(pid, {:serve, request}) + end + + @impl true + def init(network) do + {:ok, %{network: normalize_network(network), started_at: DateTime.utc_now()}} + end + + @impl true + def handle_call({:serve, request}, _from, state) do + response = do_serve(state, request) + {:reply, response, state} + end + + defp do_serve(%{network: network}, request) do + roots = public_roots(network) + + case roots do + [] -> + %{ + status: 404, + content_type: "text/plain", + body: "No public website configured for #{network}" + } + + [primary | fallbacks] -> + static_response(primary, request.path, fallbacks) + end + end + + defp static_response(root, path, fallback_roots \\ []) do + file_path = resolve_path(root, path) + + cond do + file_path == :invalid -> + %{status: 403, content_type: "text/plain", body: "Forbidden"} + + File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) -> + send_file(Path.join(file_path, "index.html")) + + File.regular?(file_path) -> + send_file(file_path) + + versioned = find_versioned_file([root | fallback_roots], path) -> + send_file(versioned) + + fallback = find_fallback_file(fallback_roots, path) -> + send_file(fallback) + + true -> + %{status: 404, content_type: "text/plain", body: "Not found"} + end + end + + defp find_versioned_file(roots, path) do + case path |> String.trim_leading("/") |> Path.split() do + [_version | rest] when rest != [] -> + stripped_path = "/" <> Path.join(rest) + find_fallback_file(roots, stripped_path) + + _ -> + nil + end + end + + defp find_fallback_file(roots, path) do + Enum.find_value(roots, fn root -> + file_path = resolve_path(root, path) + + cond do + file_path == :invalid -> + nil + + File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) -> + Path.join(file_path, "index.html") + + File.regular?(file_path) -> + file_path + + true -> + nil + end + end) + end + + defp resolve_path(root, path) do + relative = + path + |> String.trim_leading("/") + |> URI.decode() + + candidate = Path.expand(relative, root) + rel_to_root = Path.relative_to(candidate, Path.expand(root)) + + if rel_to_root == ".." or String.starts_with?(rel_to_root, "../") do + :invalid + else + candidate + end + end + + defp send_file(path) do + %{status: 200, content_type: content_type(path), body: File.read!(path)} + end + + defp content_type(path) do + case Path.extname(path) do + ".html" -> "text/html" + ".css" -> "text/css" + ".js" -> "application/javascript" + ".json" -> "application/json" + ".svg" -> "image/svg+xml" + ".png" -> "image/png" + ".jpg" -> "image/jpeg" + ".jpeg" -> "image/jpeg" + ".webp" -> "image/webp" + ".gif" -> "image/gif" + ".ico" -> "image/x-icon" + ".txt" -> "text/plain" + _ -> "application/octet-stream" + end + end + + defp public_roots("cs") do + ["networks/cs/website"] + |> Enum.map(&Forum.Assets.path/1) + |> Enum.filter(&File.dir?/1) + end + + defp public_roots("comalyr") do + [ + "networks/comalyr/comalyr.com/ui/desktop", + "networks/comalyr/comalyr.com/ui" + ] + |> Enum.map(&Forum.Assets.path/1) + |> Enum.filter(&File.dir?/1) + end + + defp public_roots(network) do + [ + Path.join(["networks", network, "website"]), + Path.join(["networks", network, "public"]), + Path.join(["networks", network, "www"]) + ] + |> Enum.map(&Forum.Assets.path/1) + |> Enum.filter(&File.dir?/1) + end + + defp via(network), + do: {:via, Registry, {Forum.ProcessRegistry, {:public_site, normalize_network(network)}}} +end diff --git a/lib/forum/public_site_router.ex b/lib/forum/public_site_router.ex new file mode 100644 index 0000000..21dda72 --- /dev/null +++ b/lib/forum/public_site_router.ex @@ -0,0 +1,60 @@ +defmodule Forum.PublicSiteRouter do + @moduledoc """ + Serves one network's public website on its own port. + """ + import Plug.Conn + + def init(opts), do: opts + + def call(conn, opts) do + network = Keyword.fetch!(opts, :network) + started_at = System.monotonic_time(:microsecond) + + response = + with {:ok, pid} <- Forum.PublicSiteSupervisor.get_or_start(network) do + Forum.PublicSite.serve(pid, %{ + method: conn.method, + path: conn.request_path, + query_string: conn.query_string, + headers: conn.req_headers + }) + else + {:error, reason} -> + %{ + status: 500, + content_type: "text/plain", + body: "Unable to start public site: #{inspect(reason)}" + } + end + + duration_us = System.monotonic_time(:microsecond) - started_at + + Forum.LogStore.add(%{ + "source_ip" => remote_ip(conn), + "host" => conn.host || "", + "network" => network, + "method" => conn.method, + "path" => conn.request_path, + "query_string" => conn.query_string, + "status" => response.status, + "duration_ms" => Float.round(duration_us / 1_000, 2) + }) + + conn + |> put_resp_content_type(response.content_type) + |> send_resp(response.status, response.body) + end + + defp remote_ip(conn) do + case Plug.Conn.get_req_header(conn, "x-real-ip") do + [ip | _] -> ip + [] -> fallback_ip(conn) + end + end + + defp fallback_ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do + remote_ip |> :inet.ntoa() |> to_string() + end + + defp fallback_ip(_), do: "" +end diff --git a/lib/forum/public_site_supervisor.ex b/lib/forum/public_site_supervisor.ex new file mode 100644 index 0000000..b5cb1ec --- /dev/null +++ b/lib/forum/public_site_supervisor.ex @@ -0,0 +1,48 @@ +defmodule Forum.PublicSiteSupervisor do + @moduledoc """ + Starts one public-site process for each network with a separate port. + """ + use DynamicSupervisor + + def start_link(opts) do + DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + DynamicSupervisor.init(strategy: :one_for_one) + end + + def get_or_start(network) when is_binary(network) do + key = Forum.PublicSite.normalize_network(network) + + case Registry.lookup(Forum.ProcessRegistry, {:public_site, key}) do + [{pid, _}] -> + {:ok, pid} + + [] -> + spec = {Forum.PublicSite, network: key} + + case DynamicSupervisor.start_child(__MODULE__, spec) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + other -> other + end + end + end + + def public_sites do + Forum.ProcessRegistry + |> Registry.select([ + {{{:public_site, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]} + ]) + end + + def start_networks do + for slug <- Forum.Networks.network_slugs() do + get_or_start(slug) + end + + :ok + end +end diff --git a/lib/forum/router.ex b/lib/forum/router.ex new file mode 100644 index 0000000..5f14842 --- /dev/null +++ b/lib/forum/router.ex @@ -0,0 +1,125 @@ +defmodule Forum.Router do + use Plug.Router + use Joken.Config + require Logger + + plug Plug.Logger + plug :log_request + plug :fetch_cookies + plug :verify_session_host + plug :match + plug :dispatch + + defp verify_session_host(conn, _opts) do + if conn.host == "session.frm.so" do + case authenticate(conn) do + {:ok, claims} -> + conn + |> Plug.Conn.assign(:claims, claims) + + :error -> + conn |> send_resp(401, "unauthorized") |> halt() + end + else + conn + end + end + + defp authenticate(conn) do + with token when is_binary(token) <- conn.cookies["auth_token"], {:ok, claims} <- verify_token(token) do + {:ok, claims} + else + _ -> :error + end + end + + defp verify_token(token) do + secret = Application.fetch_env!(:forum, :auth)[:jwt_secret] + signer = Joken.Signer.create("HS256", secret) + + case Joken.verify_and_validate(%{}, token, signer) do + {:ok, claims} -> {:ok, claims} + {:error, _reason} = err -> err + end + end + + get "/admin", host: "session.frm.so", do: handle_admin(conn) + get "/admin/graphyellow.svg", host: "session.frm.so", do: admin_asset(conn, "ui/graphyellow.svg", "image/svg+xml") + get "/admin/*path", host: "session.frm.so", do: handle_admin(conn) + + get "/", host: "session.frm.so" do + user_id = conn.assigns.claims["id"] + email = conn.assigns.claims["email"] + conn + |> WebSockAdapter.upgrade(Forum.WsHandler, %{user_id: user_id, email: email}, timeout: :infinity) + |> halt() + end + + match _ do + send_resp(conn, 404, "not found") + end + + defp handle_admin(conn) do + if websocket_upgrade?(conn) do + conn + |> WebSockAdapter.upgrade(Forum.WsHandler, %{}, timeout: :infinity) + |> halt() + else + admin_page(conn) + end + end + + defp websocket_upgrade?(conn) do + connection = get_req_header(conn, "connection") |> Enum.join(",") |> String.downcase() + upgrade = get_req_header(conn, "upgrade") |> List.first() |> to_string() |> String.downcase() + + String.contains?(connection, "upgrade") and upgrade == "websocket" + end + + defp admin_page(conn) do + html = File.read!(Forum.Assets.path("ui/admin.html")) + + conn + |> put_resp_content_type("text/html") + |> send_resp(200, html) + end + + defp admin_asset(conn, path, content_type) do + conn + |> put_resp_content_type(content_type) + |> send_file(200, Forum.Assets.path(path)) + end + + defp log_request(conn, _opts) do + started_at = System.monotonic_time(:microsecond) + + Plug.Conn.register_before_send(conn, fn conn -> + duration_us = System.monotonic_time(:microsecond) - started_at + + Forum.LogStore.add(%{ + "source_ip" => remote_ip(conn), + "host" => conn.host || "", + "method" => conn.method, + "path" => conn.request_path, + "query_string" => conn.query_string, + "status" => conn.status || 0, + "duration_ms" => Float.round(duration_us / 1_000, 2) + }) + + conn + end) + end + + defp remote_ip(conn) do + case Plug.Conn.get_req_header(conn, "x-real-ip") do + [ip | _] -> ip + [] -> fallback_ip(conn) + end + end + + defp fallback_ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do + remote_ip |> :inet.ntoa() |> to_string() + end + + defp fallback_ip(_), do: "" +end diff --git a/lib/forum/vm_memory.ex b/lib/forum/vm_memory.ex new file mode 100644 index 0000000..06d3621 --- /dev/null +++ b/lib/forum/vm_memory.ex @@ -0,0 +1,24 @@ +defmodule Forum.VmMemory do + @moduledoc """ + Snapshot of BEAM VM memory categories. + + These numbers come from `:erlang.memory/0`, so they describe memory held by + the whole VM rather than memory attributed to a particular Erlang process. + """ + + @doc """ + Returns VM memory categories as JSON-friendly maps. + """ + def list do + :erlang.memory() + |> Enum.map(fn {category, bytes} -> + %{ + "category" => Atom.to_string(category), + "bytes" => bytes, + "kb" => div(bytes, 1024), + "mb" => Float.round(bytes / 1_048_576, 2) + } + end) + |> Enum.sort_by(& &1["bytes"], :desc) + end +end diff --git a/lib/forum/ws_handler.ex b/lib/forum/ws_handler.ex new file mode 100644 index 0000000..f333aa5 --- /dev/null +++ b/lib/forum/ws_handler.ex @@ -0,0 +1,110 @@ +defmodule Forum.WsHandler do + @moduledoc """ + One instance of this module runs per connected WebSocket client. + Each instance is its own BEAM process — isolated state, isolated crashes. + + Client messages arrive in `handle_in/2`. Reply with `{:push, frame, state}`, + do nothing with `{:ok, state}`, or close with `{:stop, reason, state}`. + """ + @behaviour WebSock + + require Logger + + @impl true + def init(opts) do + Registry.register(Forum.FormsSubscriberRegistry, :forms, nil) + Logger.info("ws connected: user_id=#{opts[:user_id]}") + + state = %{ + message_count: 0, + user_id: opts[:user_id], + email: opts[:email] + } + + welcome = Jason.encode!(%{ + type: "welcome to this session: ", + user_id: opts[:user_id] + }) + + schedule_heartbeat() + + {:push, {:text, welcome}, state} + end + + # Text frame from client. + # Supported commands (sent as JSON over the wire): + # {"type": "ping"} -> {"type": "pong"} + # {"type": "send", "text": "hi"} -> inserts row, returns the row + # {"type": "list"} -> returns latest 50 rows + # {"type": "get_doc"} -> returns the backend body HTML + # {"type": "reload_forms"} -> re-reads forms.html and reconciles runtimes + # {"type": "list_processes"} -> returns a snapshot of all live processes + # {"type": "list_logs"} -> returns recent request logs + # {"type": "list_vm_memory"} -> returns BEAM VM memory categories + # Anything else echoes back. + @impl true + def handle_in({raw, [opcode: :text]}, state) do + state = %{state | message_count: state.message_count + 1} + + response = + case Jason.decode(raw) do + {:ok, %{"type" => "ping"}} -> + %{type: "pong"} + + {:ok, %{"type" => "send", "text" => text}} when is_binary(text) and text != "" -> + row = Forum.DB.insert_message(text) + %{type: "inserted", row: row} + + {:ok, %{"type" => "list"}} -> + %{type: "list", rows: Forum.DB.list_messages(limit: 50)} + + {:ok, %{"type" => "get_doc"}} -> + %{type: "doc", html: Forum.Forms.html()} + + {:ok, %{"type" => "reload_forms"}} -> + :ok = Forum.Forms.reload() + %{type: "doc", html: Forum.Forms.html()} + + {:ok, %{"type" => "list_processes"}} -> + %{type: "processes", rows: Forum.Processes.list()} + + {:ok, %{"type" => "list_modules"}} -> + %{type: "modules", rows: Forum.Modules.list()} + + {:ok, %{"type" => "list_logs"}} -> + %{type: "logs", rows: Forum.LogStore.list()} + + {:ok, %{"type" => "list_vm_memory"}} -> + %{type: "vm_memory", rows: Forum.VmMemory.list()} + + _ -> + %{type: "echo", raw: raw, count: state.message_count} + end + + {:push, {:text, Jason.encode!(response)}, state} + end + + # Binary frame from client — ignore in this scaffold. + def handle_in({_data, [opcode: :binary]}, state), do: {:ok, state} + + # Messages sent to this process's mailbox from other processes. + @impl true + def handle_info({:forms_reloaded, html}, state) do + {:push, {:text, Jason.encode!(%{type: "doc", html: html})}, state} + end + + def handle_info(_msg, state), do: {:ok, state} + + def handle_info(:heartbeat, state) do + schedule_heartbeat() + {:push, {:ping, ""}, state} + end + + defp schedule_heartbeat, do: Process.send_after(self(), :heartbeat, 30_000) + + @impl true + def terminate(reason, _state) do + Logger.info("ws disconnected: #{inspect(reason)}") + :ok + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..9759cf9 --- /dev/null +++ b/mix.exs @@ -0,0 +1,33 @@ +defmodule Forum.MixProject do + use Mix.Project + + def project do + [ + app: :forum, + version: "0.1.0", + elixir: "~> 1.16", + deps: deps() + ] + end + + def application do + [ + mod: {Forum.Application, []}, + extra_applications: [:logger, :crypto, :inets, :ssl] + ] + end + + defp deps do + [ + {:bandit, "~> 1.5"}, + {:plug, "~> 1.16"}, + {:websock_adapter, "~> 0.5"}, + {:postgrex, "~> 0.19"}, + {:jason, "~> 1.4"}, + {:dotenvy, "~> 0.9.0"}, + {:quickbeam, "~> 0.10.10"}, + {:floki, "~> 0.36"}, + {:joken, "~> 2.6"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..eb2ae60 --- /dev/null +++ b/mix.lock @@ -0,0 +1,27 @@ +%{ + "bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"}, + "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, + "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, + "dotenvy": {:hex, :dotenvy, "0.9.0", "aad823209cd7c13babe2dc310d9e54ce0203674cbd7631b0ced2a771e3a49532", [:mix], [], "hexpm", "ab959208a9ad02ff26ce1c5d4911668925c12a6cf58287ef77ae63161909c73b"}, + "floki": {:hex, :floki, "0.38.3", "40d291831d93f49aa360f09447cf2e2a902e33d8711e5fb22a75f3f333e9d063", [:mix], [], "hexpm", "025aa1f5f24a70cb31bfbc7011419228596f3b062d7feda617238ba4926f83cb"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, + "mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oxc": {:hex, :oxc, "0.15.1", "d4b1770f26cd80b84ac592a54db38c50af92c98ce480be2ae1badbc0eccd614e", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "1008f80d04b961f5f262a0aec99781f3a73e5c4c7fc969f4336ca96f653c7d58"}, + "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, + "quickbeam": {:hex, :quickbeam, "0.10.15", "8622e090ba8476b4a7c008ddb75c741e770f45e64f9fc30c43b02694213a8605", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.4", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.15.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:zigler, "~> 0.15.2", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.4", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2da3750f1e99ae1d97b6e99a2b8ea86ba48c49c568d8f711d65d342430dcfb86"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, + "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.4", "20a166584b94d860a983ef39c93cb0a9ce40c95c48555fc3cb6532c405558410", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "9cd6998a8ddf2f03ded1b6ebc9acb9bfa5e23b614f047583c76c01bf66c4640e"}, +} diff --git a/priv/experiments/forms.pug b/priv/experiments/forms.pug new file mode 100644 index 0000000..8db2f47 --- /dev/null +++ b/priv/experiments/forms.pug @@ -0,0 +1,319 @@ +forms- + // global + member-(id="1" email="samrussell99@pm.me" first-name="Sam" last-name="Russell" password="$argon2id$v=19$m=65536,t=3,p=4$n/8BaBisEnBaQNbkxzs1VA$dvvnupWNtB5w5qTBgEciDsNA6rOgXaEypcEK1A0ndLM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="CEO" bio="This is my bio" notes="no notes" created="2026-01-15 09:58:01.0072" updated-at="2026-01-15 09:58:01.0072") + member-(id="2" email="freddyjkrueger@gmail.com" first-name="Freddy" last-name="Krueger" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Villain" bio="This is my bio" notes="no notes" created="2026-01-13 13:38:46.0810" updated-at="2026-01-13 13:38:46.0810") + member-(id="3" email="harmysmarmy@gmail.com" first-name="Harmy" last-name="Smarmy" password="$argon2id$v=19$m=65536,t=3,p=4$FAhGtCtqNAQ19tBYD73wXQ$0AM/khyBFFuX2mv0ieqtGfsXRgtEldWKFwyeV3BA3Xk" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-01-13 13:41:41.0722" updated-at="2026-01-13 13:41:41.0722") + member-(id="4" email="matiascarulli@gmail.com" first-name="Matias" last-name="Carulli" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 address NW 12th St" city="Miramar" state="Florida" zipcode="33029" country="US" county="Broward" phone="123-456-789" title="Developer" bio="This is my bio" image-path="/db/images/users/member-4/profile.png" notes="no notes" created="2026-03-15 13:41:41.0722" updated-at="2026-03-15 13:41:41.0722") + member-(id="5" email="boulder@example.com" first-name="CU" last-name="Boulder" password="$argon2id$v=19$m=65536,t=3,p=4$CQwOYXNwwsLBP1s/zcZNJg$OM/wwVP5U+QUnAEDKAjk5mpvujpOzpT0XkouDcmHT8E" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-03-26 01:03:18.803016+00" updated-at="2026-03-26 01:03:18.803016+00") + member-(id="6" email="sarah.mcintyre@example.com" first-name="Sarah" last-name="McIntyre" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00") + member-(id="7" email="marcus.webb@example.com" first-name="Marcus" last-name="Webb" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Engineer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00") + member-(id="8" email="priya.anand@example.com" first-name="Priya" last-name="Anand" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="PM" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00") + member-(id="9" email="jordan.kim@example.com" first-name="Jordan" last-name="Kim" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00") + app-(id="1" name="settings") + app-(id="2" name="people") + app-(id="3" name="calendar") + app-(id="4" name="treasury") + app-(id="5" name="politics") + app-(id="6" name="files") + app-(id="7" name="jobs") + app-(id="8" name="tasks") + app-(id="9" name="chat") + app-(id="10" name="announcements") + permission-(id="1" key="events.add" app-id="3" description="Can add events") + permission-(id="2" key="events.delete" app-id="3" description="Can delete events") + permission-(id="3" key="events.edit" app-id="3" description="Can edit events") + permission-(id="4" key="events.get" app-id="3" description="Can view events") + permission-(id="5" key="jobs.add" app-id="7" description="Can add jobs") + permission-(id="6" key="jobs.delete" app-id="7" description="Can delete jobs") + permission-(id="7" key="jobs.edit" app-id="7" description="Can edit jobs") + permission-(id="8" key="jobs.get" app-id="7" description="Can view jobs") + permission-(id="9" key="announcements.add" app-id="10" description="Can add announcements") + permission-(id="10" key="announcements.delete" app-id="10" description="Can delete announcements") + permission-(id="11" key="announcements.edit" app-id="10" description="Can edit announcements") + permission-(id="12" key="announcements.get" app-id="10" description="Can view announcements") + permission-(id="13" key="role_apps.edit" app-id="1" description="Can edit role apps") + permission-(id="14" key="roles.create" app-id="1" description="Can create roles") + permission-(id="15" key="roles.delete" app-id="1" description="Can delete roles") + permission-(id="16" key="role_notifications.edit" app-id="1" description="Can edit role notifications") + permission-(id="17" key="chats.create" app-id="9" description="Can create chats") + permission-(id="18" key="chats.edit" app-id="9" description="Can edit chats") + permission-(id="19" key="chats.delete" app-id="9" description="Can delete chats") + permission-(id="20" key="chats_message.send" app-id="9" description="Can send messages") + permission-(id="21" key="chats_message.edit" app-id="9" description="Can edit messages") + permission-(id="22" key="chats_message.delete" app-id="9" description="Can delete messages") + member-app-(id="1" member-id="1" app-id="1") + member-app-(id="2" member-id="4" app-id="1") + member-app-(id="3" member-id="6" app-id="1") + member-app-(id="4" member-id="1" app-id="2") + member-app-(id="5" member-id="4" app-id="2") + member-app-(id="6" member-id="6" app-id="2") + member-app-(id="7" member-id="1" app-id="3") + member-app-(id="8" member-id="4" app-id="3") + member-app-(id="9" member-id="6" app-id="3") + member-app-(id="10" member-id="1" app-id="4") + member-app-(id="11" member-id="4" app-id="4") + member-app-(id="12" member-id="6" app-id="4") + member-app-(id="13" member-id="1" app-id="5") + member-app-(id="14" member-id="4" app-id="5") + member-app-(id="15" member-id="6" app-id="5") + member-app-(id="16" member-id="1" app-id="6") + member-app-(id="17" member-id="4" app-id="6") + member-app-(id="18" member-id="6" app-id="6") + member-app-(id="19" member-id="1" app-id="7") + member-app-(id="20" member-id="4" app-id="7") + member-app-(id="21" member-id="6" app-id="7") + member-app-(id="22" member-id="1" app-id="8") + member-app-(id="23" member-id="4" app-id="8") + member-app-(id="24" member-id="6" app-id="8") + member-app-(id="25" member-id="1" app-id="9") + member-app-(id="26" member-id="4" app-id="10") + member-app-(id="27" member-id="6" app-id="9") + // network 1: Captured Sun + network-(id="1" name="Captured Sun" logo="cs.svg" abbreviation="cs" stripe-account-id="acct_1Sn6DwLpyskwAml9" created="2026-01-10 09:58:01.0074") + network-plan-(id="1" network-id="1" stripe-price-id="price_1T3uaxLpyskwAml9p0r0nh2h" name="Patron Membership" price="200.00" description="Members 40+" active="true" created="2026-03-29 22:14:45.414163") + network-plan-(id="2" network-id="1" stripe-price-id="price_1T3uaQLpyskwAml9rZAKBcy0" name="Regular Membership" price="100.00" description="Members 18-40" active="true" created="2026-03-29 22:14:45.414163") + join-code-(id="1" code="cs" network-id="1") + member-network-(id="1" member-id="1" network-id="1" created="2025-11-24 00:54:36.0784") + member-network-(id="2" member-id="2" network-id="1" created="2026-01-13 13:14:28.0178") + member-network-(id="3" member-id="3" network-id="1" created="2026-01-13 13:28:35.0701") + role-(id="1" network-id="1" name="admin" is-default="false") + role-(id="2" network-id="1" name="member" is-default="true") + role-app-(id="1" role-id="1" app-id="1" network-id="1") + role-app-(id="2" role-id="1" app-id="2" network-id="1") + role-app-(id="3" role-id="1" app-id="3" network-id="1") + role-app-(id="4" role-id="1" app-id="4" network-id="1") + role-app-(id="5" role-id="1" app-id="5" network-id="1") + role-app-(id="6" role-id="1" app-id="6" network-id="1") + role-app-(id="7" role-id="1" app-id="7" network-id="1") + role-app-(id="8" role-id="1" app-id="8" network-id="1") + role-app-(id="9" role-id="1" app-id="9" network-id="1") + role-app-(id="10" role-id="1" app-id="10" network-id="1") + role-permission-(id="1" role-id="1" permission-key="events.get" network-id="1") + role-permission-(id="2" role-id="1" permission-key="jobs.get" network-id="1") + role-permission-(id="3" role-id="1" permission-key="announcements.get" network-id="1") + role-permission-(id="4" role-id="1" permission-key="events.add" network-id="1") + role-permission-(id="5" role-id="1" permission-key="events.delete" network-id="1") + role-permission-(id="6" role-id="1" permission-key="events.edit" network-id="1") + role-permission-(id="7" role-id="1" permission-key="jobs.add" network-id="1") + role-permission-(id="8" role-id="1" permission-key="jobs.delete" network-id="1") + role-permission-(id="9" role-id="1" permission-key="jobs.edit" network-id="1") + role-permission-(id="10" role-id="1" permission-key="announcements.add" network-id="1") + role-permission-(id="11" role-id="1" permission-key="announcements.delete" network-id="1") + role-permission-(id="12" role-id="1" permission-key="announcements.edit" network-id="1") + role-permission-(id="13" role-id="1" permission-key="role_apps.edit" network-id="1") + role-permission-(id="14" role-id="1" permission-key="roles.create" network-id="1") + role-permission-(id="15" role-id="1" permission-key="roles.delete" network-id="1") + role-permission-(id="16" role-id="1" permission-key="chats.create" network-id="1") + role-permission-(id="17" role-id="1" permission-key="chats.edit" network-id="1") + role-permission-(id="18" role-id="1" permission-key="chats.delete" network-id="1") + role-permission-(id="19" role-id="1" permission-key="chats_message.send" network-id="1") + role-permission-(id="20" role-id="1" permission-key="chats_message.edit" network-id="1") + role-permission-(id="21" role-id="1" permission-key="chats_message.delete" network-id="1") + role-permission-(id="22" role-id="1" permission-key="role_notifications.edit" network-id="1") + role-permission-(id="23" role-id="2" permission-key="events.get" network-id="1") + role-permission-(id="24" role-id="2" permission-key="jobs.get" network-id="1") + role-permission-(id="25" role-id="2" permission-key="announcements.get" network-id="1") + role-permission-(id="26" role-id="2" permission-key="events.add" network-id="1") + role-permission-(id="27" role-id="2" permission-key="chats.create" network-id="1") + role-permission-(id="28" role-id="2" permission-key="chats_message.send" network-id="1") + member-role-(id="1" member-id="1" role-id="1" granted-by="1" network-id="1") + member-role-(id="2" member-id="2" role-id="2" granted-by="1" network-id="1") + member-role-(id="3" member-id="3" role-id="2" granted-by="1" network-id="1") + join-form-(schema="org_1" id="1" fname="James" lname="Mitchell" email="james.mitchell@gmail.com" phone="512-555-0101" county="Comal" time="2025-12-16 23:11:31.0011" network-id="1") + join-form-(schema="org_1" id="2" fname="Rachel" lname="Torres" email="rachel.torres@yahoo.com" phone="512-555-0102" county="Bexar" time="2025-12-19 19:23:12.0717" network-id="1") + join-form-(schema="org_1" id="3" fname="David" lname="Nguyen" email="david.nguyen@gmail.com" phone="830-555-0103" county="Comal" time="2026-01-06 16:55:29.0288" network-id="1") + join-form-(schema="org_1" id="4" fname="Emily" lname="Sanders" email="emily.sanders@outlook.com" phone="210-555-0104" county="Hays" time="2026-01-07 17:14:01.0711" network-id="1") + contact-form-(schema="org_1" id="1" fname="Marcus" lname="Webb" email="marcus.webb@gmail.com" phone="512-555-0201" county="Comal" message="Interested in volunteering at upcoming events." time="2025-12-29 13:20:28.0157" network-id="1") + contact-form-(schema="org_1" id="2" fname="Sandra" lname="Holloway" email="sandra.holloway@gmail.com" phone="830-555-0202" county="Comal" message="Would love to connect with your organization." time="2025-12-30 22:10:24.0971" network-id="1") + contact-form-(schema="org_1" id="3" fname="Robert" lname="Finley" email="robert.finley@gmail.com" phone="210-555-0203" county="Comal" message="Looking forward to getting more involved locally." time="2026-01-10 21:23:51.0073" network-id="1") + contact-form-(schema="org_1" id="4" fname="Barbara" lname="Crane" email="barbara.crane@outlook.com" phone="512-555-0204" county="Comal" message="Please reach out regarding the next meeting schedule." time="2026-01-10 21:23:54.0841" network-id="1") + // network 2: Hyperia + network-(id="2" name="Hyperia" logo="hyperia.svg" abbreviation="hyperia" stripe-account-id="acct_1S4w0GHZemeF9CKR" created="2026-01-10 09:58:01.0074") + join-code-(id="2" code="hyperia" network-id="2") + member-network-(id="4" member-id="1" network-id="2" created="2026-01-13 13:28:35.0701") + member-network-(id="5" member-id="4" network-id="2" created="2026-03-15 13:28:35.0701") + member-network-(id="6" member-id="6" network-id="2" created="2026-02-01 09:00:00+00") + member-network-(id="7" member-id="7" network-id="2" created="2026-02-01 09:00:00+00") + member-network-(id="8" member-id="8" network-id="2" created="2026-02-01 09:00:00+00") + member-network-(id="9" member-id="9" network-id="2" created="2026-02-01 09:00:00+00") + role-(id="3" network-id="2" name="admin" is-default="false") + role-(id="4" network-id="2" name="member" is-default="true") + role-app-(id="11" role-id="3" app-id="1" network-id="2") + role-app-(id="12" role-id="3" app-id="2" network-id="2") + role-app-(id="13" role-id="3" app-id="3" network-id="2") + role-app-(id="14" role-id="3" app-id="4" network-id="2") + role-app-(id="15" role-id="3" app-id="5" network-id="2") + role-app-(id="16" role-id="3" app-id="6" network-id="2") + role-app-(id="17" role-id="3" app-id="7" network-id="2") + role-app-(id="18" role-id="3" app-id="8" network-id="2") + role-app-(id="19" role-id="3" app-id="9" network-id="2") + role-app-(id="20" role-id="3" app-id="10" network-id="2") + role-permission-(id="29" role-id="3" permission-key="events.get" network-id="2") + role-permission-(id="30" role-id="3" permission-key="jobs.get" network-id="2") + role-permission-(id="31" role-id="3" permission-key="announcements.get" network-id="2") + role-permission-(id="32" role-id="3" permission-key="events.add" network-id="2") + role-permission-(id="33" role-id="3" permission-key="events.delete" network-id="2") + role-permission-(id="34" role-id="3" permission-key="events.edit" network-id="2") + role-permission-(id="35" role-id="3" permission-key="jobs.add" network-id="2") + role-permission-(id="36" role-id="3" permission-key="jobs.delete" network-id="2") + role-permission-(id="37" role-id="3" permission-key="jobs.edit" network-id="2") + role-permission-(id="38" role-id="3" permission-key="announcements.add" network-id="2") + role-permission-(id="39" role-id="3" permission-key="announcements.delete" network-id="2") + role-permission-(id="40" role-id="3" permission-key="announcements.edit" network-id="2") + role-permission-(id="41" role-id="3" permission-key="role_apps.edit" network-id="2") + role-permission-(id="42" role-id="3" permission-key="roles.create" network-id="2") + role-permission-(id="43" role-id="3" permission-key="roles.delete" network-id="2") + role-permission-(id="44" role-id="3" permission-key="chats.create" network-id="2") + role-permission-(id="45" role-id="3" permission-key="chats.edit" network-id="2") + role-permission-(id="46" role-id="3" permission-key="chats.delete" network-id="2") + role-permission-(id="47" role-id="3" permission-key="chats_message.send" network-id="2") + role-permission-(id="48" role-id="3" permission-key="chats_message.edit" network-id="2") + role-permission-(id="49" role-id="3" permission-key="chats_message.delete" network-id="2") + role-permission-(id="50" role-id="3" permission-key="role_notifications.edit" network-id="2") + role-permission-(id="51" role-id="4" permission-key="events.get" network-id="2") + role-permission-(id="52" role-id="4" permission-key="jobs.get" network-id="2") + role-permission-(id="53" role-id="4" permission-key="announcements.get" network-id="2") + role-permission-(id="54" role-id="4" permission-key="events.add" network-id="2") + role-permission-(id="55" role-id="4" permission-key="chats.create" network-id="2") + role-permission-(id="56" role-id="4" permission-key="chats_message.send" network-id="2") + member-role-(id="4" member-id="1" role-id="3" granted-by="1" network-id="2") + member-role-(id="5" member-id="4" role-id="3" granted-by="1" network-id="2") + member-role-(id="6" member-id="6" role-id="3" granted-by="1" network-id="2") + member-role-(id="7" member-id="6" role-id="4" granted-by="1" network-id="2") + member-role-(id="8" member-id="7" role-id="4" granted-by="1" network-id="2") + member-role-(id="9" member-id="8" role-id="4" granted-by="1" network-id="2") + member-role-(id="10" member-id="9" role-id="4" granted-by="1" network-id="2") + calendar-(schema="events" id="1" network-id="2" owner-id="1" name="Main Calendar" description="The main calendar for the network" color="#9E1C29") + calendar-(schema="events" id="2" network-id="2" owner-id="1" name="Sub-Calendar" description="Sub-calendar for the network" color="#3D6FAD") + calendar-(schema="events" id="3" network-id="2" owner-id="1" name="Sub-Calendar 2" description="Another sub-calendar for the network" color="#2A8636") + event-recurrence-(schema="events" id="1" frequency="weekly" interval="1" days-of-week="{2}" network-id="2") + event-recurrence-(schema="events" id="2" frequency="weekly" interval="2" days-of-week="{3}" count="10" network-id="2") + event-recurrence-(schema="events" id="3" frequency="weekly" interval="1" days-of-week="{1,3,5}" network-id="2") + event-(schema="events" id="1" network-id="2" creator-id="1" title="Client meeting" description="Meeting with big client for app deployment" time-start="2026-04-01T17:30:00.000Z" time-end="2026-04-01T19:00:00.000Z" location="Virtual" all-day="false" recurrence-id="2") + event-(schema="events" id="2" network-id="2" creator-id="1" title="Networking Event" description="Networking event for young professionals" time-start="2026-04-04T04:00:00.000Z" time-end="2026-04-06T04:00:00.000Z" location="GB Center" all-day="true") + event-(schema="events" id="3" network-id="2" creator-id="1" title="App deployment party" description="Come celebrate the app deployment!" time-start="2026-04-05T16:00:00.000Z" time-end="2026-04-05T21:30:00.000Z" location="Captured Sun HQ" all-day="false") + event-(schema="events" id="4" network-id="2" creator-id="4" title="Work on frontend changes" description="Reminder for work #1" time-start="2026-04-02T15:00:00.000Z" time-end="2026-04-02T19:00:00.000Z" all-day="false") + event-(schema="events" id="5" network-id="2" creator-id="4" title="Day off" description="I dont have to work today" time-start="2026-04-03T04:00:00.000Z" time-end="2026-04-03T04:00:00.000Z" all-day="true") + event-(schema="events" id="6" network-id="2" creator-id="4" title="Scrum Meeting - Main Team" description="Agile Week 32" time-start="2026-03-31T03:00:00.000Z" time-end="2026-03-31T06:15:00.000Z" location="Virtual" all-day="false" recurrence-id="1") + event-(schema="events" id="7" network-id="2" creator-id="1" title="Meeting with John Smiith" description="Lorem ipsum elorum" time-start="2026-03-31T18:30:00.000Z" time-end="2026-03-31T19:30:00.000Z" location="Virtual" all-day="false") + event-(schema="events" id="8" network-id="2" creator-id="1" title="Meeting with Jane Doe" description="Lorem ipsum elorum" time-start="2026-03-31T19:30:00.000Z" time-end="2026-03-31T19:45:00.000Z" location="Virtual" all-day="false") + event-(schema="events" id="9" network-id="2" creator-id="1" title="Meeting with president" description="Lorem ipsum elorum" time-start="2026-03-31T19:45:00.000Z" time-end="2026-03-31T21:15:00.000Z" location="Virtual" all-day="false") + event-(schema="events" id="10" network-id="2" creator-id="1" title="Meeting with investors" description="Lorem ipsum elorum" time-start="2026-03-31T21:30:00.000Z" time-end="2026-03-31T23:00:00.000Z" location="Virtual" all-day="false") + event-(schema="events" id="11" network-id="2" creator-id="1" title="Review Github changes" description="Review pushes from members to ensure all is well" time-start="2026-04-03T04:00:00.000Z" time-end="2026-04-03T04:00:00.000Z" all-day="true" recurrence-id="3") + event-calendar-(schema="events" id="1" event-id="1" calendar-id="1" network-id="2") + event-calendar-(schema="events" id="2" event-id="2" calendar-id="2" network-id="2") + event-calendar-(schema="events" id="3" event-id="3" calendar-id="1" network-id="2") + event-calendar-(schema="events" id="4" event-id="3" calendar-id="3" network-id="2") + event-calendar-(schema="events" id="5" event-id="4" calendar-id="2" network-id="2") + event-calendar-(schema="events" id="6" event-id="5" calendar-id="2" network-id="2") + event-calendar-(schema="events" id="7" event-id="6" calendar-id="1" network-id="2") + event-calendar-(schema="events" id="8" event-id="6" calendar-id="2" network-id="2") + event-calendar-(schema="events" id="9" event-id="7" calendar-id="3" network-id="2") + event-calendar-(schema="events" id="10" event-id="8" calendar-id="2" network-id="2") + event-calendar-(schema="events" id="11" event-id="8" calendar-id="3" network-id="2") + event-calendar-(schema="events" id="12" event-id="9" calendar-id="3" network-id="2") + event-calendar-(schema="events" id="13" event-id="10" calendar-id="3" network-id="2") + event-calendar-(schema="events" id="14" event-id="11" calendar-id="3" network-id="2") + job-(id="1" network-id="2" creator-id="1" title="Senior Frontend Engineer" company="Acme Corp" location="San Francisco, CA" employment-type="full-time" experience-level="senior" department="Engineering" salary-number="165000" salary-period="year" applicants="47" skills="React,TypeScript,CSS,GraphQL,Node.js" description="We're looking for a Senior Frontend Engineer to join our growing product team. You'll work closely with designers, backend engineers, and product managers to build fast, accessible, and beautiful web experiences. You'll have ownership over large features and be expected to make architectural decisions. We ship every week and care deeply about code quality and UX." created="2026-04-29 10:00:00" updated-at="2026-04-29 10:00:00") + job-(id="2" network-id="2" creator-id="1" title="Product Designer" company="Blue River" location="New York, NY" employment-type="full-time" experience-level="mid" department="Design" salary-number="120000" salary-period="year" applicants="112" skills="Figma,Design Systems,Prototyping,User Research" description="Blue River is hiring a Product Designer to lead design across our core consumer product. You'll partner with PMs and engineers to take features from zero to one, establish design patterns, and advocate for users in every decision. We're a small, fast-moving team and designers have a huge impact here." created="2026-04-26 09:00:00" updated-at="2026-04-26 09:00:00") + job-(id="3" network-id="2" creator-id="1" title="Backend Engineer" company="Orbit Systems" location="Remote" employment-type="contract" experience-level="mid" department="Platform" salary-number="95" salary-period="hour" applicants="29" skills="Go,PostgreSQL,Kubernetes,gRPC,Redis" description="6-month contract (potential to extend) for a backend engineer to help scale our platform infrastructure. You'll be responsible for building and maintaining APIs used by millions of users, optimizing database performance, and improving system reliability." created="2026-04-30 14:00:00" updated-at="2026-04-30 14:00:00") + job-(id="4" network-id="2" creator-id="1" title="Marketing Manager" company="Groundwork" location="Austin, TX" employment-type="full-time" experience-level="mid" department="Marketing" salary-number="98000" salary-period="year" applicants="88" skills="SEO,Content Strategy,Analytics,Paid Acquisition,Email Marketing" description="We need a Marketing Manager to own our top-of-funnel growth. You'll manage content, paid channels, and email campaigns — and be the person who keeps the brand consistent across every touchpoint. You'll report directly to our Head of Growth." created="2026-04-21 11:00:00" updated-at="2026-04-21 11:00:00") + job-(id="5" network-id="2" creator-id="1" title="Data Analyst" company="Compass Data" location="Chicago, IL" employment-type="full-time" experience-level="entry" department="Analytics" salary-number="75000" salary-period="year" applicants="203" skills="SQL,Python,Tableau,dbt,Excel" description="A great entry-level opportunity for someone who loves data. You'll work with our analytics team to build dashboards, run ad-hoc analysis, and help business teams make data-driven decisions. We'll invest in your growth and give you plenty of mentorship." created="2026-04-28 08:30:00" updated-at="2026-04-28 08:30:00") + job-(id="6" network-id="2" creator-id="1" title="iOS Engineer" company="Fieldwork" location="Seattle, WA" employment-type="full-time" experience-level="senior" department="Mobile" salary-number="175000" salary-period="year" applicants="34" skills="Swift,SwiftUI,Combine,CoreData,Xcode" description="Fieldwork is building next-generation tools for field service teams. Our iOS app is the most important surface we have — technicians use it every day on job sites. We need a senior iOS engineer who cares about performance, offline reliability, and a great UX." created="2026-04-24 13:00:00" updated-at="2026-04-24 13:00:00") + job-(id="7" network-id="2" creator-id="1" title="Operations Coordinator" company="Maple & Co" location="Boston, MA" employment-type="part-time" experience-level="entry" department="Operations" salary-number="28" salary-period="hour" applicants="61" skills="Project Management,Excel,Communication,Scheduling" description="Part-time role (20 hrs/week) helping our operations team stay organized. You'll coordinate schedules, manage vendor relationships, and help improve internal workflows. Ideal for someone who's highly organized and excited to grow into a full-time ops role." created="2026-04-17 10:00:00" updated-at="2026-04-17 10:00:00") + job-(id="8" network-id="2" creator-id="1" title="Machine Learning Intern" company="NeuralPath" location="Remote" employment-type="internship" experience-level="entry" department="AI Research" salary-number="8000" salary-period="month" applicants="394" skills="Python,PyTorch,Linear Algebra,Git" description="Summer internship (12 weeks) on our ML research team. You'll work alongside researchers on real problems in NLP and recommendation systems. We expect you to ship something you're proud of by the end of the summer. Strong preference for candidates who can start June 2." created="2026-05-01 09:00:00" updated-at="2026-05-01 09:00:00") + announcement-(id="1" network-id="2" creator-id="1" text="This is the first announcement" created="2026-01-13 15:37:00.0000" updated-at="2026-01-13 15:37:00.0000") + announcement-(id="2" network-id="2" creator-id="1" text="Here goes another announcement." created="2026-01-13 15:39:00.0000" updated-at="2026-01-13 15:39:00.0000") + announcement-(id="3" network-id="2" creator-id="4" text="My first announcement!" created="2026-01-14 15:41:00.0000" updated-at="2026-01-14 15:41:00.0000") + announcement-(id="4" network-id="2" creator-id="1" text="Testing announcement." created="2026-02-17 12:30:00.0000" updated-at="2026-02-17 12:30:00.0000") + announcement-(id="5" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:56:00.0000" updated-at="2026-03-23 15:56:00.0000") + announcement-(id="6" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:56:30.0000" updated-at="2026-03-23 15:56:30.0000") + announcement-(id="7" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:57:00.0000" updated-at="2026-03-23 15:57:00.0000") + announcement-(id="8" network-id="2" creator-id="1" text="Trying another user." created="2026-02-05 15:56:30.0000" updated-at="2026-02-05 15:56:30.0000") + announcement-(id="9" network-id="2" creator-id="1" text="One last announcement." created="2026-04-01 15:57:00.0000" updated-at="2026-01-13 15:57:00.0000") + chat-(schema="chats" id="1" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:42:00+00") + chat-(schema="chats" id="2" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 10:30:00+00") + chat-(schema="chats" id="3" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-04-30 18:00:00+00") + chat-(schema="chats" id="4" network-id="2" creator-id="4" type="group" name="Product Team" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:51:00+00") + chat-(schema="chats" id="5" network-id="2" creator-id="4" type="group" name="Design Review" created="2026-04-01 09:00:00+00" updated-at="2026-04-30 10:00:00+00") + chat-(schema="chats" id="6" network-id="2" creator-id="4" type="channel" name="general" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:55:00+00") + chat-(schema="chats" id="7" network-id="2" creator-id="4" type="channel" name="engineering" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00") + chat-(schema="chats" id="8" network-id="2" creator-id="8" type="announcement" name="Announcements" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 00:00:00+00") + chat-member-(schema="chats" id="1" chat-id="1" member-id="4" last-read-at="2026-05-01 07:12:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="2" chat-id="1" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="3" chat-id="2" member-id="4" last-read-at="2026-05-01 10:30:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="4" chat-id="2" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="5" chat-id="3" member-id="4" last-read-at="2026-04-30 18:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="6" chat-id="3" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="7" chat-id="4" member-id="4" last-read-at="2026-05-01 04:30:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="8" chat-id="4" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="9" chat-id="4" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="10" chat-id="4" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="11" chat-id="5" member-id="4" last-read-at="2026-04-30 10:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="12" chat-id="5" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="13" chat-id="5" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="14" chat-id="6" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="15" chat-id="6" member-id="4" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="16" chat-id="6" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="17" chat-id="6" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="18" chat-id="6" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="19" chat-id="6" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="20" chat-id="7" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="21" chat-id="7" member-id="4" last-read-at="2026-05-01 09:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="22" chat-id="7" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="23" chat-id="8" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="24" chat-id="8" member-id="4" last-read-at="2026-04-29 12:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="25" chat-id="8" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="26" chat-id="8" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="27" chat-id="8" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2") + chat-member-(schema="chats" id="28" chat-id="8" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2") + message-(schema="chats" id="1" chat-id="1" sender-id="4" text="Hey Sarah, did you see the new design mockups?" sent-at="2026-04-30 12:00:00+00" updated-at="2026-04-30 12:00:00+00" network-id="2") + message-(schema="chats" id="2" chat-id="1" sender-id="6" text="Just looked — they're really clean. I love the new sidebar." sent-at="2026-04-30 12:30:00+00" updated-at="2026-04-30 12:30:00+00" network-id="2") + message-(schema="chats" id="3" chat-id="1" sender-id="4" text="Agreed. Alex did a great job." sent-at="2026-04-30 12:36:00+00" updated-at="2026-04-30 12:36:00+00" network-id="2") + message-(schema="chats" id="4" chat-id="1" sender-id="6" text="Are we going to ship this week or wait for the backend?" sent-at="2026-05-01 07:00:00+00" updated-at="2026-05-01 07:00:00+00" network-id="2") + message-(schema="chats" id="5" chat-id="1" sender-id="4" text="Let's aim for Thursday. I'll sync with Marcus." sent-at="2026-05-01 07:12:00+00" updated-at="2026-05-01 07:12:00+00" network-id="2") + message-(schema="chats" id="6" chat-id="1" sender-id="6" text="Sounds good 👍" sent-at="2026-05-01 07:18:00+00" updated-at="2026-05-01 07:18:00+00" network-id="2") + message-(schema="chats" id="7" chat-id="1" sender-id="6" text="Can you review the PR when you get a chance?" sent-at="2026-05-01 11:42:00+00" updated-at="2026-05-01 11:42:00+00" network-id="2") + message-(schema="chats" id="8" chat-id="2" sender-id="7" text="Hey, the API endpoint is returning 500s on staging." sent-at="2026-05-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00" network-id="2") + message-(schema="chats" id="9" chat-id="2" sender-id="4" text="Oh no — is it the auth middleware again?" sent-at="2026-05-01 09:06:00+00" updated-at="2026-05-01 09:06:00+00" network-id="2") + message-(schema="chats" id="10" chat-id="2" sender-id="7" text="Yep. Same issue as last week." sent-at="2026-05-01 09:12:00+00" updated-at="2026-05-01 09:12:00+00" network-id="2") + message-(schema="chats" id="11" chat-id="2" sender-id="4" text="I'll patch it now. Give me 20 mins." sent-at="2026-05-01 09:15:00+00" updated-at="2026-05-01 09:15:00+00" network-id="2") + message-(schema="chats" id="12" chat-id="2" sender-id="7" text="Thanks, no rush." sent-at="2026-05-01 09:18:00+00" updated-at="2026-05-01 09:18:00+00" network-id="2") + message-(schema="chats" id="13" chat-id="2" sender-id="4" text="Fixed. Can you redeploy and check?" sent-at="2026-05-01 09:48:00+00" updated-at="2026-05-01 09:48:00+00" network-id="2") + message-(schema="chats" id="14" chat-id="2" sender-id="7" text="All green 🎉 Thanks!" sent-at="2026-05-01 09:54:00+00" updated-at="2026-05-01 09:54:00+00" network-id="2") + message-(schema="chats" id="15" chat-id="2" sender-id="4" text="I'll send over the specs by EOD" sent-at="2026-05-01 10:30:00+00" updated-at="2026-05-01 10:30:00+00" network-id="2") + message-(schema="chats" id="16" chat-id="3" sender-id="8" text="Quick question — what's the launch date for v2?" sent-at="2026-04-30 16:00:00+00" updated-at="2026-04-30 16:00:00+00" network-id="2") + message-(schema="chats" id="17" chat-id="3" sender-id="4" text="Still TBD, but we're targeting end of May." sent-at="2026-04-30 16:12:00+00" updated-at="2026-04-30 16:12:00+00" network-id="2") + message-(schema="chats" id="18" chat-id="3" sender-id="8" text="Got it. I'll update the roadmap doc." sent-at="2026-04-30 16:30:00+00" updated-at="2026-04-30 16:30:00+00" network-id="2") + message-(schema="chats" id="19" chat-id="3" sender-id="4" text="Perfect, thanks Priya." sent-at="2026-04-30 16:36:00+00" updated-at="2026-04-30 16:36:00+00" network-id="2") + message-(schema="chats" id="20" chat-id="3" sender-id="8" text="See you at the standup!" sent-at="2026-04-30 18:00:00+00" updated-at="2026-04-30 18:00:00+00" network-id="2") + message-(schema="chats" id="21" chat-id="4" sender-id="7" text="Morning everyone! API docs are updated." sent-at="2026-05-01 04:00:00+00" updated-at="2026-05-01 04:00:00+00" network-id="2") + message-(schema="chats" id="22" chat-id="4" sender-id="8" text="Nice work Marcus 🙌" sent-at="2026-05-01 04:06:00+00" updated-at="2026-05-01 04:06:00+00" network-id="2") + message-(schema="chats" id="23" chat-id="4" sender-id="4" text="I'll start on the integration tests today." sent-at="2026-05-01 04:12:00+00" updated-at="2026-05-01 04:12:00+00" network-id="2") + message-(schema="chats" id="24" chat-id="4" sender-id="6" text="Great. I'm finishing up the onboarding screens." sent-at="2026-05-01 04:30:00+00" updated-at="2026-05-01 04:30:00+00" network-id="2") + message-(schema="chats" id="25" chat-id="4" sender-id="8" text="Can we do a quick sync at 2pm?" sent-at="2026-05-01 10:00:00+00" updated-at="2026-05-01 10:00:00+00" network-id="2") + message-(schema="chats" id="26" chat-id="4" sender-id="4" text="Works for me." sent-at="2026-05-01 10:03:00+00" updated-at="2026-05-01 10:03:00+00" network-id="2") + message-(schema="chats" id="27" chat-id="4" sender-id="7" text="Same" sent-at="2026-05-01 10:06:00+00" updated-at="2026-05-01 10:06:00+00" network-id="2") + message-(schema="chats" id="28" chat-id="4" sender-id="6" text="I'll send the invite." sent-at="2026-05-01 10:09:00+00" updated-at="2026-05-01 10:09:00+00" network-id="2") + message-(schema="chats" id="29" chat-id="4" sender-id="6" text="I've updated the Figma file with the new flows" sent-at="2026-05-01 11:51:00+00" updated-at="2026-05-01 11:51:00+00" network-id="2") + message-(schema="chats" id="30" chat-id="5" sender-id="9" text="Hey, sharing the first round of designs for the settings page." sent-at="2026-04-30 06:00:00+00" updated-at="2026-04-30 06:00:00+00" network-id="2") + message-(schema="chats" id="31" chat-id="5" sender-id="6" text="These look great! Love the card layout." sent-at="2026-04-30 06:30:00+00" updated-at="2026-04-30 06:30:00+00" network-id="2") + message-(schema="chats" id="32" chat-id="5" sender-id="4" text="Agreed. One thought — the spacing on the form feels a bit tight." sent-at="2026-04-30 07:00:00+00" updated-at="2026-04-30 07:00:00+00" network-id="2") + message-(schema="chats" id="33" chat-id="5" sender-id="9" text="Good call. I'll loosen it up." sent-at="2026-04-30 07:12:00+00" updated-at="2026-04-30 07:12:00+00" network-id="2") + message-(schema="chats" id="34" chat-id="5" sender-id="6" text="Also maybe we increase the font size slightly?" sent-at="2026-04-30 08:00:00+00" updated-at="2026-04-30 08:00:00+00" network-id="2") + message-(schema="chats" id="35" chat-id="5" sender-id="9" text="The contrast on mobile looks off — can we bump it?" sent-at="2026-04-30 10:00:00+00" updated-at="2026-04-30 10:00:00+00" network-id="2") + message-(schema="chats" id="36" chat-id="6" sender-id="8" text="Good morning team! Reminder: all-hands is Thursday at 10am." sent-at="2026-05-01 03:00:00+00" updated-at="2026-05-01 03:00:00+00" network-id="2") + message-(schema="chats" id="37" chat-id="6" sender-id="6" text="Thanks for the reminder!" sent-at="2026-05-01 03:06:00+00" updated-at="2026-05-01 03:06:00+00" network-id="2") + message-(schema="chats" id="38" chat-id="6" sender-id="9" text="Will there be a recording for those in other time zones?" sent-at="2026-05-01 03:18:00+00" updated-at="2026-05-01 03:18:00+00" network-id="2") + message-(schema="chats" id="39" chat-id="6" sender-id="8" text="Yes — I'll post the link in #announcements after." sent-at="2026-05-01 03:24:00+00" updated-at="2026-05-01 03:24:00+00" network-id="2") + message-(schema="chats" id="40" chat-id="6" sender-id="4" text="Thanks Priya 🙏" sent-at="2026-05-01 03:30:00+00" updated-at="2026-05-01 03:30:00+00" network-id="2") + message-(schema="chats" id="41" chat-id="6" sender-id="7" text="Staging is back up btw, had a brief outage this morning." sent-at="2026-05-01 08:00:00+00" updated-at="2026-05-01 08:00:00+00" network-id="2") + message-(schema="chats" id="42" chat-id="6" sender-id="6" text="Oh I didn't even notice, nice quick fix!" sent-at="2026-05-01 08:12:00+00" updated-at="2026-05-01 08:12:00+00" network-id="2") + message-(schema="chats" id="43" chat-id="6" sender-id="7" text="Just pushed the hotfix to production" sent-at="2026-05-01 11:55:00+00" updated-at="2026-05-01 11:55:00+00" network-id="2") + message-(schema="chats" id="44" chat-id="7" sender-id="7" text="Heads up: I'm updating the CI pipeline today. Builds might be slow for a bit." sent-at="2026-05-01 07:00:00+00" updated-at="2026-05-01 07:00:00+00" network-id="2") + message-(schema="chats" id="45" chat-id="7" sender-id="4" text="Noted, thanks for the warning." sent-at="2026-05-01 07:06:00+00" updated-at="2026-05-01 07:06:00+00" network-id="2") + message-(schema="chats" id="46" chat-id="7" sender-id="7" text="Back to normal now." sent-at="2026-05-01 08:00:00+00" updated-at="2026-05-01 08:00:00+00" network-id="2") + message-(schema="chats" id="47" chat-id="7" sender-id="4" text="PR is up: #247 — adds rate limiting to the auth routes" sent-at="2026-05-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00" network-id="2") + message-(schema="chats" id="48" chat-id="8" sender-id="8" text="Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer." sent-at="2026-04-28 12:00:00+00" updated-at="2026-04-28 12:00:00+00" network-id="2") + message-(schema="chats" id="49" chat-id="8" sender-id="8" text="Reminder: expense reports for March are due this Friday." sent-at="2026-04-29 12:00:00+00" updated-at="2026-04-29 12:00:00+00" network-id="2") + message-(schema="chats" id="50" chat-id="8" sender-id="8" text="Q2 planning kick-off is next Monday at 9am. Please come prepared with your team's priorities." sent-at="2026-05-01 00:00:00+00" updated-at="2026-05-01 00:00:00+00" network-id="2") diff --git a/priv/experiments/forms.sql b/priv/experiments/forms.sql new file mode 100644 index 0000000..ead04b3 --- /dev/null +++ b/priv/experiments/forms.sql @@ -0,0 +1,336 @@ +-- members +INSERT INTO members (email, first_name, last_name, password, address1, address2, city, state, zipcode, country, county, phone, title, bio, image_path, notes, created, updated_at) VALUES +('samrussell99@pm.me', 'Sam', 'Russell', '$argon2id$v=19$m=65536,t=3,p=4$n/8BaBisEnBaQNbkxzs1VA$dvvnupWNtB5w5qTBgEciDsNA6rOgXaEypcEK1A0ndLM', '1234 address NW 12th St', NULL, 'Austin', 'Texas', '12345', 'US', 'Austin County', '123-456-789', 'CEO', 'This is my bio', NULL, 'no notes', '2026-01-15 09:58:01.0072', '2026-01-15 09:58:01.0072'), +('freddyjkrueger@gmail.com', 'Freddy','Krueger', '$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM', '1234 address NW 12th St', NULL, 'Austin', 'Texas', '12345', 'US', 'Austin County', '123-456-789', 'Villain', 'This is my bio', NULL, 'no notes', '2026-01-13 13:38:46.0810', '2026-01-13 13:38:46.0810'), +('harmysmarmy@gmail.com', 'Harmy','Smarmy', '$argon2id$v=19$m=65536,t=3,p=4$FAhGtCtqNAQ19tBYD73wXQ$0AM/khyBFFuX2mv0ieqtGfsXRgtEldWKFwyeV3BA3Xk', '1234 address NW 12th St', NULL, 'Austin', 'Texas', '12345', 'US', 'Austin County', '123-456-789', 'Associate', 'This is my bio', NULL, 'no notes', '2026-01-13 13:41:41.0722', '2026-01-13 13:41:41.0722'), +('matiascarulli@gmail.com', 'Matias', 'Carulli', '$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg', '1234 address NW 12th St', NULL, 'Miramar', 'Florida', '33029', 'US', 'Broward', '123-456-789', 'Developer', 'This is my bio', '/db/images/users/member-4/profile.png', 'no notes', '2026-03-15 13:41:41.0722', '2026-03-15 13:41:41.0722'), +('boulder@example.com', 'CU', 'Boulder', '$argon2id$v=19$m=65536,t=3,p=4$CQwOYXNwwsLBP1s/zcZNJg$OM/wwVP5U+QUnAEDKAjk5mpvujpOzpT0XkouDcmHT8E', '1234 address NW 12th St', NULL, 'Austin', 'Texas', '12345', 'US', 'Austin County', '123-456-789', 'Associate', 'This is my bio', NULL, 'no notes', '2026-03-26 01:03:18.803016+00', '2026-03-26 01:03:18.803016+00'); + + +-- networks +INSERT INTO networks (name, logo, abbreviation, stripe_account_id, created) VALUES +('Captured Sun', 'cs.svg', 'cs', 'acct_1Sn6DwLpyskwAml9', '2026-01-10 09:58:01.0074'), +('Hyperia', 'hyperia.svg', 'hyperia', 'acct_1S4w0GHZemeF9CKR', '2026-01-10 09:58:01.0074'); + +-- apps +INSERT INTO apps (name) VALUES +('settings'), +('people'), +('calendar'), +('treasury'), +('politics'), +('files'), +('jobs'), +('tasks'), +('chat'), +('announcements'); + +INSERT INTO roles (network_id, name, is_default) VALUES +(1, 'admin', false), +(1, 'member', true), +(2, 'admin', false), +(2, 'member', true); + +-- network_apps +INSERT INTO role_apps (role_id, app_id) VALUES +(1, 1), +(1, 2), +(1, 3), +(1, 4), +(1, 5), +(1, 6), +(1, 7), +(1, 8), +(1, 9), +(1, 10), +(3, 1), +(3, 2), +(3, 3), +(3, 4), +(3, 5), +(3, 6), +(3, 7), +(3, 8), +(3, 9), +(3, 10); + +-- member_networks +INSERT INTO member_networks (member_id, network_id, created) VALUES +(1, 1, '2025-11-24 00:54:36.0784'), +(2, 1, '2026-01-13 13:14:28.0178'), +(3, 1, '2026-01-13 13:28:35.0701'), +(1, 2, '2026-01-13 13:28:35.0701'), +(4, 2, '2026-03-15 13:28:35.0701'); + +-- network_plans +INSERT INTO network_plans (id, network_id, stripe_price_id, name, price, description, active, created) VALUES +(1, 1, 'price_1T3uaxLpyskwAml9p0r0nh2h', 'Patron Membership', 200.00, 'Members 40+', true, '2026-03-29 22:14:45.414163'), +(2, 1, 'price_1T3uaQLpyskwAml9rZAKBcy0', 'Regular Membership', 100.00, 'Members 18-40', true, '2026-03-29 22:14:45.414163'); + +-- join form seed data +INSERT INTO org_1.join_form (fname, lname, email, phone, county, time) VALUES +('James', 'Mitchell', 'james.mitchell@gmail.com', '512-555-0101', 'Comal', '2025-12-16 23:11:31.0011'), +('Rachel', 'Torres', 'rachel.torres@yahoo.com', '512-555-0102', 'Bexar', '2025-12-19 19:23:12.0717'), +('David', 'Nguyen', 'david.nguyen@gmail.com', '830-555-0103', 'Comal', '2026-01-06 16:55:29.0288'), +('Emily', 'Sanders', 'emily.sanders@outlook.com', '210-555-0104', 'Hays', '2026-01-07 17:14:01.0711'); + +-- contact form seed data +INSERT INTO org_1.contact_form (fname, lname, email, phone, county, message, time) VALUES +('Marcus', 'Webb', 'marcus.webb@gmail.com', '512-555-0201', 'Comal', 'Interested in volunteering at upcoming events.', '2025-12-29 13:20:28.0157'), +('Sandra', 'Holloway', 'sandra.holloway@gmail.com', '830-555-0202', 'Comal', 'Would love to connect with your organization.', '2025-12-30 22:10:24.0971'), +('Robert', 'Finley', 'robert.finley@gmail.com', '210-555-0203', 'Comal', 'Looking forward to getting more involved locally.', '2026-01-10 21:23:51.0073'), +('Barbara', 'Crane', 'barbara.crane@outlook.com', '512-555-0204', 'Comal', 'Please reach out regarding the next meeting schedule.', '2026-01-10 21:23:54.0841'); + +-- calendars table seed data +INSERT INTO events.calendars (network_id, owner_id, name, description, color) VALUES +(2, 1, 'Main Calendar', 'The main calendar for the network', '#9E1C29'), +(2, 1, 'Sub-Calendar', 'Sub-calendar for the network', '#3D6FAD'), +(2, 1, 'Sub-Calendar 2', 'Another sub-calendar for the network', '#2A8636'); + +-- events table seed data +INSERT INTO events.events (network_id, creator_id, title, description, time_start, time_end, location, all_day) VALUES +(2, 1, 'Client meeting', 'Meeting with big client for app deployment', '2026-04-01T17:30:00.000Z', '2026-04-01T19:00:00.000Z', 'Virtual', false), +(2, 1, 'Networking Event', 'Networking event for young professionals', '2026-04-04T04:00:00.000Z', '2026-04-06T04:00:00.000Z', 'GB Center', true), +(2, 1, 'App deployment party', 'Come celebrate the app deployment!', '2026-04-05T16:00:00.000Z', '2026-04-05T21:30:00.000Z', 'Captured Sun HQ', false), +(2, 4, 'Work on frontend changes', 'Reminder for work #1', '2026-04-02T15:00:00.000Z', '2026-04-02T19:00:00.000Z', null, false), +(2, 4, 'Day off', 'I dont have to work today', '2026-04-03T04:00:00.000Z', '2026-04-03T04:00:00.000Z', null, true), +(2, 4, 'Scrum Meeting - Main Team', 'Agile Week 32', '2026-03-31T03:00:00.000Z', '2026-03-31T06:15:00.000Z', 'Virtual', false), +(2, 1, 'Meeting with John Smiith', 'Lorem ipsum elorum', '2026-03-31T18:30:00.000Z', '2026-03-31T19:30:00.000Z', 'Virtual', false), +(2, 1, 'Meeting with Jane Doe', 'Lorem ipsum elorum', '2026-03-31T19:30:00.000Z', '2026-03-31T19:45:00.000Z', 'Virtual', false), +(2, 1, 'Meeting with president', 'Lorem ipsum elorum', '2026-03-31T19:45:00.000Z', '2026-03-31T21:15:00.000Z', 'Virtual', false), +(2, 1, 'Meeting with investors', 'Lorem ipsum elorum', '2026-03-31T21:30:00.000Z', '2026-03-31T23:00:00.000Z', 'Virtual', false), +(2, 1, 'Review Github changes', 'Review pushes from members to ensure all is well', '2026-04-03T04:00:00.000Z', '2026-04-03T04:00:00.000Z', null, true); + +INSERT INTO events.event_calendars (event_id, calendar_id) VALUES +(1, 1), +(2, 2), +(3, 1), +(3, 3), +(4, 2), +(5, 2), +(6, 1), +(6, 2), +(7, 3), +(8, 2), +(8, 3), +(9, 3), +(10, 3), +(11, 3); + +-- jobs table seed data +INSERT INTO public.jobs (network_id, creator_id, title, company, location, employment_type, experience_level, department, salary_number, salary_period, applicants, skills, description, created, updated_at) VALUES +(2, 1, 'Senior Frontend Engineer', 'Acme Corp', 'San Francisco, CA', 'full-time', 'senior', 'Engineering', 165000, 'year', 47, ARRAY['React', 'TypeScript', 'CSS', 'GraphQL', 'Node.js'], E'We''re looking for a Senior Frontend Engineer to join our growing product team. You''ll work closely with designers, backend engineers, and product managers to build fast, accessible, and beautiful web experiences.\n\nYou''ll have ownership over large features and be expected to make architectural decisions. We ship every week and care deeply about code quality and UX.', '2026-04-29 10:00:00', '2026-04-29 10:00:00'), +(2, 1, 'Product Designer', 'Blue River', 'New York, NY', 'full-time', 'mid', 'Design', 120000, 'year', 112, ARRAY['Figma', 'Design Systems', 'Prototyping', 'User Research'], E'Blue River is hiring a Product Designer to lead design across our core consumer product. You''ll partner with PMs and engineers to take features from zero to one, establish design patterns, and advocate for users in every decision.\n\nWe''re a small, fast-moving team and designers have a huge impact here.', '2026-04-26 09:00:00', '2026-04-26 09:00:00'), +(2, 1, 'Backend Engineer', 'Orbit Systems', 'Remote', 'contract', 'mid', 'Platform', 95, 'hour', 29, ARRAY['Go', 'PostgreSQL', 'Kubernetes', 'gRPC', 'Redis'], '6-month contract (potential to extend) for a backend engineer to help scale our platform infrastructure. You''ll be responsible for building and maintaining APIs used by millions of users, optimizing database performance, and improving system reliability.', '2026-04-30 14:00:00', '2026-04-30 14:00:00'), +(2, 1, 'Marketing Manager', 'Groundwork', 'Austin, TX', 'full-time', 'mid', 'Marketing', 98000, 'year', 88, ARRAY['SEO', 'Content Strategy', 'Analytics', 'Paid Acquisition', 'Email Marketing'], E'We need a Marketing Manager to own our top-of-funnel growth. You''ll manage content, paid channels, and email campaigns — and be the person who keeps the brand consistent across every touchpoint. You''ll report directly to our Head of Growth.', '2026-04-21 11:00:00', '2026-04-21 11:00:00'), +(2, 1, 'Data Analyst', 'Compass Data', 'Chicago, IL', 'full-time', 'entry', 'Analytics', 75000, 'year', 203, ARRAY['SQL', 'Python', 'Tableau', 'dbt', 'Excel'], 'A great entry-level opportunity for someone who loves data. You''ll work with our analytics team to build dashboards, run ad-hoc analysis, and help business teams make data-driven decisions. We''ll invest in your growth and give you plenty of mentorship.', '2026-04-28 08:30:00', '2026-04-28 08:30:00'), +(2, 1, 'iOS Engineer', 'Fieldwork', 'Seattle, WA', 'full-time', 'senior', 'Mobile', 175000, 'year', 34, ARRAY['Swift', 'SwiftUI', 'Combine', 'CoreData', 'Xcode'], E'Fieldwork is building next-generation tools for field service teams. Our iOS app is the most important surface we have — technicians use it every day on job sites. We need a senior iOS engineer who cares about performance, offline reliability, and a great UX.', '2026-04-24 13:00:00', '2026-04-24 13:00:00'), +(2, 1, 'Operations Coordinator', 'Maple & Co', 'Boston, MA', 'part-time', 'entry', 'Operations', 28, 'hour', 61, ARRAY['Project Management', 'Excel', 'Communication', 'Scheduling'], E'Part-time role (20 hrs/week) helping our operations team stay organized. You''ll coordinate schedules, manage vendor relationships, and help improve internal workflows. Ideal for someone who''s highly organized and excited to grow into a full-time ops role.', '2026-04-17 10:00:00', '2026-04-17 10:00:00'), +(2, 1, 'Machine Learning Intern', 'NeuralPath', 'Remote', 'internship', 'entry', 'AI Research', 8000, 'month', 394, ARRAY['Python', 'PyTorch', 'Linear Algebra', 'Git'], 'Summer internship (12 weeks) on our ML research team. You''ll work alongside researchers on real problems in NLP and recommendation systems. We expect you to ship something you''re proud of by the end of the summer. Strong preference for candidates who can start June 2.', '2026-05-01 09:00:00', '2026-05-01 09:00:00'); + +INSERT INTO permissions (key, app_id, description) VALUES +('events.add', 3, 'Can add events'), +('events.delete', 3, 'Can delete events'), +('events.edit', 3, 'Can edit events'), +('events.get', 3, 'Can view events'), +('jobs.add', 7, 'Can add jobs'), +('jobs.delete', 7, 'Can delete jobs'), +('jobs.edit', 7, 'Can edit jobs'), +('jobs.get', 7, 'Can view jobs'), +('announcements.add', 10, 'Can add announcements'), +('announcements.delete', 10, 'Can delete announcements'), +('announcements.edit', 10, 'Can edit announcements'), +('announcements.get', 10, 'Can view announcements'), +('role_apps.edit', 1, 'Can edit role apps'), +('roles.create', 1, 'Can create roles'), +('roles.delete', 1, 'Can delete roles'), +('role_notifications.edit', 1, 'Can edit role notifications'), +('chats.create', 9, 'Can create chats'), +('chats.edit', 9, 'Can edit chats'), +('chats.delete', 9, 'Can delete chats'), +('chats_message.send', 9, 'Can send messages'), +('chats_message.edit', 9, 'Can edit messages'), +('chats_message.delete', 9, 'Can delete messages'); + +INSERT INTO role_permissions (role_id, permission_key) VALUES +(1, 'events.get'), (1, 'jobs.get'), (1, 'announcements.get'), (1, 'events.add'), (1, 'events.delete'), (1, 'events.edit'), (1, 'jobs.add'), (1, 'jobs.delete'), (1, 'jobs.edit'), (1, 'announcements.add'), (1, 'announcements.delete'), (1, 'announcements.edit'), (1, 'role_apps.edit'), (1, 'roles.create'), (1, 'roles.delete'), (1, 'chats.create'), (1, 'chats.edit'), (1, 'chats.delete'), (1, 'chats_message.send'), (1, 'chats_message.edit'), (1, 'chats_message.delete'), (1, 'role_notifications.edit'),-- network 1 admin +(2, 'events.get'), (2, 'jobs.get'), (2, 'announcements.get'), (2, 'events.add'), (2, 'chats.create'), (2, 'chats_message.send'), -- network 1 user +(3, 'events.get'), (3, 'jobs.get'), (3, 'announcements.get'), (3, 'events.add'), (3, 'events.delete'), (3, 'events.edit'), (3, 'jobs.add'), (3, 'jobs.delete'), (3, 'jobs.edit'), (3, 'announcements.add'), (3, 'announcements.delete'), (3, 'announcements.edit'), (3, 'role_apps.edit'), (3, 'roles.create'), (3, 'roles.delete'), (3, 'chats.create'), (3, 'chats.edit'), (3, 'chats.delete'), (3, 'chats_message.send'), (3, 'chats_message.edit'), (3, 'chats_message.delete'), (3, 'role_notifications.edit'),-- network 2 admin +(4, 'events.get'), (4, 'jobs.get'), (4, 'announcements.get'), (4, 'events.add'), (4, 'chats.create'), (4, 'chats_message.send'); -- network 2 user + +INSERT INTO member_roles (member_id, role_id, granted_by) VALUES +(1, 1, 1), (2, 2, 1), (3, 2, 1), -- network 1 +(1, 3, 1), (4, 3, 1), (6, 3, 1); -- network 2 + +INSERT INTO announcements (network_id, creator_id, text, created, updated_at) VALUES +(2, 1, 'This is the first announcement', '2026-01-13 15:37:00.0000', '2026-01-13 15:37:00.0000'), +(2, 1, 'Here goes another announcement.', '2026-01-13 15:39:00.0000', '2026-01-13 15:39:00.0000'), +(2, 4, 'My first announcement!', '2026-01-14 15:41:00.0000', '2026-01-14 15:41:00.0000'), +(2, 1, 'Testing announcement.', '2026-02-17 12:30:00.0000', '2026-02-17 12:30:00.0000'), +(2, 4, 'Quick fire!', '2026-03-23 15:56:00.0000', '2026-03-23 15:56:00.0000'), +(2, 4, 'Quick fire!', '2026-03-23 15:56:30.0000', '2026-03-23 15:56:30.0000'), +(2, 4, 'Quick fire!', '2026-03-23 15:57:00.0000', '2026-03-23 15:57:00.0000'), +(2, 1, 'Trying another user.', '2026-02-05 15:56:30.0000', '2026-02-05 15:56:30.0000'), +(2, 1, 'One last announcement.', '2026-04-01 15:57:00.0000', '2026-01-13 15:57:00.0000'); + +INSERT INTO public.join_code (code, network_id) VALUES +('cs', 1), +('hyperia', 2); + +-- Recurrence rules for 3 seed events (0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat) +INSERT INTO events.event_recurrence (frequency, interval, days_of_week, end_date, count) VALUES +('weekly', 1, '{2}', null, null), -- id=1: every Tuesday → Scrum Meeting +('weekly', 2, '{3}', null, 10 ), -- id=2: every 2nd Wednesday x10 → Client Meeting +('weekly', 1, '{1,3,5}', null, null); -- id=3: every Mon/Wed/Fri → Review Github + +UPDATE events.events SET recurrence_id = 1 WHERE id = 6; +UPDATE events.events SET recurrence_id = 2 WHERE id = 1; +UPDATE events.events SET recurrence_id = 3 WHERE id = 11; + +ALTER TABLE public.files ALTER COLUMN type TYPE varchar(100); + +-- chat seed members (Sarah McIntyre=6, Marcus Webb=7, Priya Anand=8, Jordan Kim=9) +INSERT INTO members (email, first_name, last_name, password, address1, address2, city, state, zipcode, country, county, phone, title, bio, image_path, notes, created, updated_at) VALUES +('sarah.mcintyre@example.com', 'Sarah', 'McIntyre', '$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg', '1234 Address St', NULL, 'Austin', 'Texas', '12345', 'US', 'Travis', '123-456-789', 'Designer', 'This is my bio', NULL, NULL, '2026-02-01 09:00:00+00', '2026-02-01 09:00:00+00'), +('marcus.webb@example.com', 'Marcus', 'Webb', '$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM', '1234 Address St', NULL, 'Austin', 'Texas', '12345', 'US', 'Travis', '123-456-789', 'Engineer', 'This is my bio', NULL, NULL, '2026-02-01 09:00:00+00', '2026-02-01 09:00:00+00'), +('priya.anand@example.com', 'Priya', 'Anand', '$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM', '1234 Address St', NULL, 'Austin', 'Texas', '12345', 'US', 'Travis', '123-456-789', 'PM', 'This is my bio', NULL, NULL, '2026-02-01 09:00:00+00', '2026-02-01 09:00:00+00'), +('jordan.kim@example.com', 'Jordan', 'Kim', '$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM', '1234 Address St', NULL, 'Austin', 'Texas', '12345', 'US', 'Travis', '123-456-789', 'Designer', 'This is my bio', NULL, NULL, '2026-02-01 09:00:00+00', '2026-02-01 09:00:00+00'); + +INSERT INTO member_networks (member_id, network_id, created) VALUES +(6, 2, '2026-02-01 09:00:00+00'), +(7, 2, '2026-02-01 09:00:00+00'), +(8, 2, '2026-02-01 09:00:00+00'), +(9, 2, '2026-02-01 09:00:00+00'); + +INSERT INTO member_roles (member_id, role_id, granted_by) VALUES +(6, 4, 1), +(7, 4, 1), +(8, 4, 1), +(9, 4, 1); + +-- chats (network 2 / Hyperia) +-- updated_at reflects the last message's sent_at for each chat +INSERT INTO chats.chats (network_id, creator_id, type, name, created, updated_at) VALUES +(2, 4, 'dm', NULL, '2026-04-01 09:00:00+00', '2026-05-01 11:42:00+00'), -- 1: DM Matias↔Sarah +(2, 4, 'dm', NULL, '2026-04-01 09:00:00+00', '2026-05-01 10:30:00+00'), -- 2: DM Matias↔Marcus +(2, 4, 'dm', NULL, '2026-04-01 09:00:00+00', '2026-04-30 18:00:00+00'), -- 3: DM Matias↔Priya +(2, 4, 'group', 'Product Team', '2026-04-01 09:00:00+00', '2026-05-01 11:51:00+00'), -- 4 +(2, 4, 'group', 'Design Review', '2026-04-01 09:00:00+00', '2026-04-30 10:00:00+00'), -- 5 +(2, 4, 'channel', 'general', '2026-04-01 09:00:00+00', '2026-05-01 11:55:00+00'), -- 6 +(2, 4, 'channel', 'engineering', '2026-04-01 09:00:00+00', '2026-05-01 09:00:00+00'), -- 7 +(2, 8, 'announcement', 'Announcements', '2026-04-01 09:00:00+00', '2026-05-01 00:00:00+00'); -- 8 + +-- chat_members +-- last_read_at for member 4 (Matias) is set to match the unread counts from the hardcoded data +INSERT INTO chats.chat_members (chat_id, member_id, last_read_at, joined_at) VALUES +-- chat 1: DM Sarah (unread=2 → Matias last read before the final 2 messages from Sarah) +(1, 4, '2026-05-01 07:12:00+00', '2026-04-01 09:00:00+00'), +(1, 6, NULL, '2026-04-01 09:00:00+00'), +-- chat 2: DM Marcus (unread=0) +(2, 4, '2026-05-01 10:30:00+00', '2026-04-01 09:00:00+00'), +(2, 7, NULL, '2026-04-01 09:00:00+00'), +-- chat 3: DM Priya (unread=0) +(3, 4, '2026-04-30 18:00:00+00', '2026-04-01 09:00:00+00'), +(3, 8, NULL, '2026-04-01 09:00:00+00'), +-- chat 4: Product Team (unread=5 → Matias last read after the 4th message) +(4, 4, '2026-05-01 04:30:00+00', '2026-04-01 09:00:00+00'), +(4, 6, NULL, '2026-04-01 09:00:00+00'), +(4, 7, NULL, '2026-04-01 09:00:00+00'), +(4, 8, NULL, '2026-04-01 09:00:00+00'), +-- chat 5: Design Review (unread=0) +(5, 4, '2026-04-30 10:00:00+00', '2026-04-01 09:00:00+00'), +(5, 6, NULL, '2026-04-01 09:00:00+00'), +(5, 9, NULL, '2026-04-01 09:00:00+00'), +-- chat 6: #general (unread=11 → all unread, NULL) +(6, 1, NULL, '2026-04-01 09:00:00+00'), +(6, 4, NULL, '2026-04-01 09:00:00+00'), +(6, 6, NULL, '2026-04-01 09:00:00+00'), +(6, 7, NULL, '2026-04-01 09:00:00+00'), +(6, 8, NULL, '2026-04-01 09:00:00+00'), +(6, 9, NULL, '2026-04-01 09:00:00+00'), +-- chat 7: #engineering (unread=0) +(7, 1, NULL, '2026-04-01 09:00:00+00'), +(7, 4, '2026-05-01 09:00:00+00', '2026-04-01 09:00:00+00'), +(7, 7, NULL, '2026-04-01 09:00:00+00'), +-- chat 8: Announcements (unread=1 → Matias last read after the 2nd message) +(8, 1, NULL, '2026-04-01 09:00:00+00'), +(8, 4, '2026-04-29 12:00:00+00', '2026-04-01 09:00:00+00'), +(8, 6, NULL, '2026-04-01 09:00:00+00'), +(8, 7, NULL, '2026-04-01 09:00:00+00'), +(8, 8, NULL, '2026-04-01 09:00:00+00'), +(8, 9, NULL, '2026-04-01 09:00:00+00'); + +-- messages +-- Timestamps computed from "now" = 2026-05-01 12:00:00+00, matching ago(h) from the hardcoded constructor +-- sender_id mapping: Matias=4, Sarah=6, Marcus=7, Priya=8, Jordan=9 +INSERT INTO chats.messages (chat_id, sender_id, text, sent_at, updated_at) VALUES +-- chat 1: DM Matias↔Sarah +(1, 4, 'Hey Sarah, did you see the new design mockups?', '2026-04-30 12:00:00+00', '2026-04-30 12:00:00+00'), +(1, 6, 'Just looked — they''re really clean. I love the new sidebar.','2026-04-30 12:30:00+00', '2026-04-30 12:30:00+00'), +(1, 4, 'Agreed. Alex did a great job.', '2026-04-30 12:36:00+00', '2026-04-30 12:36:00+00'), +(1, 6, 'Are we going to ship this week or wait for the backend?', '2026-05-01 07:00:00+00', '2026-05-01 07:00:00+00'), +(1, 4, 'Let''s aim for Thursday. I''ll sync with Marcus.', '2026-05-01 07:12:00+00', '2026-05-01 07:12:00+00'), +(1, 6, 'Sounds good 👍', '2026-05-01 07:18:00+00', '2026-05-01 07:18:00+00'), +(1, 6, 'Can you review the PR when you get a chance?', '2026-05-01 11:42:00+00', '2026-05-01 11:42:00+00'), +-- chat 2: DM Matias↔Marcus +(2, 7, 'Hey, the API endpoint is returning 500s on staging.', '2026-05-01 09:00:00+00', '2026-05-01 09:00:00+00'), +(2, 4, 'Oh no — is it the auth middleware again?', '2026-05-01 09:06:00+00', '2026-05-01 09:06:00+00'), +(2, 7, 'Yep. Same issue as last week.', '2026-05-01 09:12:00+00', '2026-05-01 09:12:00+00'), +(2, 4, 'I''ll patch it now. Give me 20 mins.', '2026-05-01 09:15:00+00', '2026-05-01 09:15:00+00'), +(2, 7, 'Thanks, no rush.', '2026-05-01 09:18:00+00', '2026-05-01 09:18:00+00'), +(2, 4, 'Fixed. Can you redeploy and check?', '2026-05-01 09:48:00+00', '2026-05-01 09:48:00+00'), +(2, 7, 'All green 🎉 Thanks!', '2026-05-01 09:54:00+00', '2026-05-01 09:54:00+00'), +(2, 4, 'I''ll send over the specs by EOD', '2026-05-01 10:30:00+00', '2026-05-01 10:30:00+00'), +-- chat 3: DM Matias↔Priya +(3, 8, 'Quick question — what''s the launch date for v2?', '2026-04-30 16:00:00+00', '2026-04-30 16:00:00+00'), +(3, 4, 'Still TBD, but we''re targeting end of May.', '2026-04-30 16:12:00+00', '2026-04-30 16:12:00+00'), +(3, 8, 'Got it. I''ll update the roadmap doc.', '2026-04-30 16:30:00+00', '2026-04-30 16:30:00+00'), +(3, 4, 'Perfect, thanks Priya.', '2026-04-30 16:36:00+00', '2026-04-30 16:36:00+00'), +(3, 8, 'See you at the standup!', '2026-04-30 18:00:00+00', '2026-04-30 18:00:00+00'), +-- chat 4: Product Team +(4, 7, 'Morning everyone! API docs are updated.', '2026-05-01 04:00:00+00', '2026-05-01 04:00:00+00'), +(4, 8, 'Nice work Marcus 🙌', '2026-05-01 04:06:00+00', '2026-05-01 04:06:00+00'), +(4, 4, 'I''ll start on the integration tests today.', '2026-05-01 04:12:00+00', '2026-05-01 04:12:00+00'), +(4, 6, 'Great. I''m finishing up the onboarding screens.', '2026-05-01 04:30:00+00', '2026-05-01 04:30:00+00'), +(4, 8, 'Can we do a quick sync at 2pm?', '2026-05-01 10:00:00+00', '2026-05-01 10:00:00+00'), +(4, 4, 'Works for me.', '2026-05-01 10:03:00+00', '2026-05-01 10:03:00+00'), +(4, 7, 'Same', '2026-05-01 10:06:00+00', '2026-05-01 10:06:00+00'), +(4, 6, 'I''ll send the invite.', '2026-05-01 10:09:00+00', '2026-05-01 10:09:00+00'), +(4, 6, 'I''ve updated the Figma file with the new flows', '2026-05-01 11:51:00+00', '2026-05-01 11:51:00+00'), +-- chat 5: Design Review +(5, 9, 'Hey, sharing the first round of designs for the settings page.', '2026-04-30 06:00:00+00', '2026-04-30 06:00:00+00'), +(5, 6, 'These look great! Love the card layout.', '2026-04-30 06:30:00+00', '2026-04-30 06:30:00+00'), +(5, 4, 'Agreed. One thought — the spacing on the form feels a bit tight.', '2026-04-30 07:00:00+00', '2026-04-30 07:00:00+00'), +(5, 9, 'Good call. I''ll loosen it up.', '2026-04-30 07:12:00+00', '2026-04-30 07:12:00+00'), +(5, 6, 'Also maybe we increase the font size slightly?', '2026-04-30 08:00:00+00', '2026-04-30 08:00:00+00'), +(5, 9, 'The contrast on mobile looks off — can we bump it?', '2026-04-30 10:00:00+00', '2026-04-30 10:00:00+00'), +-- chat 6: #general +(6, 8, 'Good morning team! Reminder: all-hands is Thursday at 10am.', '2026-05-01 03:00:00+00', '2026-05-01 03:00:00+00'), +(6, 6, 'Thanks for the reminder!', '2026-05-01 03:06:00+00', '2026-05-01 03:06:00+00'), +(6, 9, 'Will there be a recording for those in other time zones?', '2026-05-01 03:18:00+00', '2026-05-01 03:18:00+00'), +(6, 8, 'Yes — I''ll post the link in #announcements after.', '2026-05-01 03:24:00+00', '2026-05-01 03:24:00+00'), +(6, 4, 'Thanks Priya 🙏', '2026-05-01 03:30:00+00', '2026-05-01 03:30:00+00'), +(6, 7, 'Staging is back up btw, had a brief outage this morning.', '2026-05-01 08:00:00+00', '2026-05-01 08:00:00+00'), +(6, 6, 'Oh I didn''t even notice, nice quick fix!', '2026-05-01 08:12:00+00', '2026-05-01 08:12:00+00'), +(6, 7, 'Just pushed the hotfix to production', '2026-05-01 11:55:00+00', '2026-05-01 11:55:00+00'), +-- chat 7: #engineering +(7, 7, 'Heads up: I''m updating the CI pipeline today. Builds might be slow for a bit.', '2026-05-01 07:00:00+00', '2026-05-01 07:00:00+00'), +(7, 4, 'Noted, thanks for the warning.', '2026-05-01 07:06:00+00', '2026-05-01 07:06:00+00'), +(7, 7, 'Back to normal now.', '2026-05-01 08:00:00+00', '2026-05-01 08:00:00+00'), +(7, 4, 'PR is up: #247 — adds rate limiting to the auth routes', '2026-05-01 09:00:00+00', '2026-05-01 09:00:00+00'), +-- chat 8: Announcements +(8, 8, 'Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer.', '2026-04-28 12:00:00+00', '2026-04-28 12:00:00+00'), +(8, 8, 'Reminder: expense reports for March are due this Friday.', '2026-04-29 12:00:00+00', '2026-04-29 12:00:00+00'), +(8, 8, 'Q2 planning kick-off is next Monday at 9am. Please come prepared with your team''s priorities.', '2026-05-01 00:00:00+00', '2026-05-01 00:00:00+00'); + +-- member_apps +INSERT INTO member_apps (member_id, app_id) VALUES +(1, 1), (4, 1), (6, 1), +(1, 2), (4, 2), (6, 2), +(1, 3), (4, 3), (6, 3), +(1, 4), (4, 4), (6, 4), +(1, 5), (4, 5), (6, 5), +(1, 6), (4, 6), (6, 6), +(1, 7), (4, 7), (6, 7), +(1, 8), (4, 8), (6, 8), +(1, 9), (4, 10), (6, 9); \ No newline at end of file diff --git a/priv/forms.html b/priv/forms.html new file mode 100644 index 0000000..4587ca9 --- /dev/null +++ b/priv/forms.html @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/js/form_init.js b/priv/js/form_init.js new file mode 100644 index 0000000..8735c25 --- /dev/null +++ b/priv/js/form_init.js @@ -0,0 +1,15 @@ +// Generic init script for a supervised form runtime. The supervisor +// (Forum.Forms) injects two globals via QuickBEAM's :define +// option before this script runs: +// +// className: string - e.g. "Task" +// formData: object - attributes + content from the source element +// +// We build a class with the requested name that extends Form, then +// instantiate it. The instance lives for the life of this runtime. + +class Form { + constructor(data) { + Object.assign(this, data); + } +} diff --git a/priv/networks/comalyr/index.html b/priv/networks/comalyr/index.html new file mode 100644 index 0000000..8227a59 --- /dev/null +++ b/priv/networks/comalyr/index.html @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/networks/cs/index.html b/priv/networks/cs/index.html new file mode 100644 index 0000000..f5ae573 --- /dev/null +++ b/priv/networks/cs/index.html @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/networks/cs/website/_/IMFell/IMFell.ttf b/priv/networks/cs/website/_/IMFell/IMFell.ttf new file mode 100644 index 0000000..65381b4 Binary files /dev/null and b/priv/networks/cs/website/_/IMFell/IMFell.ttf differ diff --git a/priv/networks/cs/website/_/IMFell/OFL.txt b/priv/networks/cs/website/_/IMFell/OFL.txt new file mode 100644 index 0000000..2318e86 --- /dev/null +++ b/priv/networks/cs/website/_/IMFell/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010, Igino Marini (mail@iginomarini.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/priv/networks/cs/website/_/atlas.webp b/priv/networks/cs/website/_/atlas.webp new file mode 100644 index 0000000..e10d7ff Binary files /dev/null and b/priv/networks/cs/website/_/atlas.webp differ diff --git a/priv/networks/cs/website/_/atlasmobile.png b/priv/networks/cs/website/_/atlasmobile.png new file mode 100644 index 0000000..d6d1df5 Binary files /dev/null and b/priv/networks/cs/website/_/atlasmobile.png differ diff --git a/priv/networks/cs/website/_/logo.svg b/priv/networks/cs/website/_/logo.svg new file mode 100644 index 0000000..ff20d2f --- /dev/null +++ b/priv/networks/cs/website/_/logo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/networks/cs/website/_/moon.png b/priv/networks/cs/website/_/moon.png new file mode 100644 index 0000000..c3eabb9 Binary files /dev/null and b/priv/networks/cs/website/_/moon.png differ diff --git a/priv/networks/cs/website/_/stars.png b/priv/networks/cs/website/_/stars.png new file mode 100644 index 0000000..990a294 Binary files /dev/null and b/priv/networks/cs/website/_/stars.png differ diff --git a/priv/networks/cs/website/_/text.png b/priv/networks/cs/website/_/text.png new file mode 100644 index 0000000..54707db Binary files /dev/null and b/priv/networks/cs/website/_/text.png differ diff --git a/priv/networks/cs/website/index.html b/priv/networks/cs/website/index.html new file mode 100644 index 0000000..9036d0b --- /dev/null +++ b/priv/networks/cs/website/index.html @@ -0,0 +1,460 @@ + + + + + + Captured Sun + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/schema.sql b/priv/schema.sql new file mode 100644 index 0000000..7bce601 --- /dev/null +++ b/priv/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS messages ( + id BIGSERIAL PRIMARY KEY, + text TEXT NOT NULL, + inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/priv/ui/admin.html b/priv/ui/admin.html new file mode 100644 index 0000000..8f21ff8 --- /dev/null +++ b/priv/ui/admin.html @@ -0,0 +1,496 @@ + + + + + admin + + + + + + + +
+
+ + + | + + +
+ + + + + + + +
pidnameinitial callmemory (KB)tree memory (KB)msgsstatus
+
+ + + + + + + + + + diff --git a/priv/ui/admin.pug b/priv/ui/admin.pug new file mode 100644 index 0000000..07afce5 --- /dev/null +++ b/priv/ui/admin.pug @@ -0,0 +1,490 @@ +doctype html +html(lang="en") + head + meta(charset="utf-8") + title admin + link(rel="icon", href="/graphyellow.svg") + style. + body { + background: #850000; + color: #FEBA7D; + font: 12px ui-monospace, monospace; + margin: 1rem; + } + .tabs { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } + .tabs button { + color: #FEBA7D; background: none; border: 1px solid transparent; font: inherit; + cursor: pointer; padding: 4px 10px; border-radius: 3px; + } + .tabs button:hover { background: #9D1A12; border-color: #C7643F; } + .tabs button.active { + font-weight: bold; color: #850000; background: #FEBA7D; + border-color: #FEBA7D; cursor: default; + } + .toolbar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; } + .toolbar button { + color: #FEBA7D; background: none; border: none; font: inherit; + cursor: pointer; padding: 0 2px; + } + .toolbar button:hover { color: #FEBA7D; text-decoration: underline; } + .toolbar button.active { font-weight: bold; color: #FEBA7D; cursor: default; } + .toolbar input { + background: #6C0000; color: #FEBA7D; font: inherit; + padding: 2px 6px; border: 1px solid #C7643F; + border-radius: 3px; width: 16rem; + } + .toolbar input::placeholder { color: #E49768; } + table { border-collapse: collapse; width: 100%; } + th, td { padding: 4px 8px; border-bottom: 0.5px solid rgba(254, 232, 200, 0.2); text-align: left; vertical-align: top; } + td { color: #5C0000; } + th { background: #6C0000; color: #FEBA7D; position: sticky; top: 0; } + tr:hover { background: #9D1A12; } + .num { text-align: right; } + .name, .mod { color: #FEBA7D; } + .src { color: #5C0000; } + #status { margin-left: auto; color: #FEBA7D; } + .disclosure { + display: inline-block; width: 1em; cursor: pointer; + color: #FEBA7D; user-select: none; + } + .disclosure.leaf { cursor: default; visibility: hidden; } + .pid-cell { white-space: pre; } + section[hidden] { display: none; } + script(src="https://frm.so/_/code/quill.js") + body + nav.tabs + button#tab-processes.active processes + button#tab-modules modules + button#tab-logs logs + button#tab-vm-memory vm memory + span#status connecting… + + section#panel-processes + .toolbar + button#p-mine.active mine + button#p-all all + span | + button#p-expand expand all + button#p-collapse collapse all + table + thead + tr + th pid + th name + th initial call + th.num memory (KB) + th.num tree memory (KB) + th.num msgs + th status + tbody#p-rows + + section#panel-modules(hidden) + .toolbar + button#m-mine.active mine + button#m-all all + input#m-filter(placeholder="filter…", autocomplete="off") + button#m-refresh refresh + table + thead + tr + th module + th app + th source + tbody#m-rows + + section#panel-logs(hidden) + .toolbar + button#l-refresh refresh + table + thead + tr + th time (Chicago) + th source ip + th host + th method + th path + th.num status + th.num duration ms + tbody#l-rows + + section#panel-vm-memory(hidden) + .toolbar + button#v-refresh refresh + table + thead + tr + th category + th.num bytes + th.num KB + th.num MB + tbody#v-rows + + script. + // ── shared infrastructure ────────────────────────────────────── + const status = document.getElementById("status"); + + const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/admin/ws"; + const ws = new WebSocket(url); + + // Per-message-type dispatch so each panel registers its own handler. + const handlers = {}; + ws.addEventListener("message", (e) => { + const msg = JSON.parse(e.data); + const h = handlers[msg.type]; + if (h) h(msg); + }); + + function send(obj) { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); + } + + function addCell(tr, text, cls) { + const td = document.createElement("td"); + if (cls) td.className = cls; + td.textContent = text; + tr.appendChild(td); + } + + // ── tabs ─────────────────────────────────────────────────────── + const tabs = { + processes: { btn: document.getElementById("tab-processes"), panel: document.getElementById("panel-processes") }, + modules: { btn: document.getElementById("tab-modules"), panel: document.getElementById("panel-modules") }, + logs: { btn: document.getElementById("tab-logs"), panel: document.getElementById("panel-logs") }, + vmMemory: { btn: document.getElementById("tab-vm-memory"), panel: document.getElementById("panel-vm-memory") }, + }; + const tabRoutes = { + processes: "processes", + modules: "modules", + logs: "logs", + vmMemory: "vm-memory", + }; + let active = "processes"; + + function tabFromLocation() { + const segment = location.pathname.split("/").filter(Boolean)[1]; + return Object.keys(tabRoutes).find(name => tabRoutes[name] === segment) || "processes"; + } + + function setAdminPath(name, mode) { + const nextPath = "/admin/" + tabRoutes[name]; + if (location.pathname === nextPath) return; + + const nextUrl = nextPath + location.search + location.hash; + if (mode === "replace") history.replaceState({tab: name}, "", nextUrl); + else history.pushState({tab: name}, "", nextUrl); + } + + function activate(name, mode) { + active = name; + setAdminPath(name, mode); + for (const [k, t] of Object.entries(tabs)) { + t.btn.classList.toggle("active", k === name); + t.panel.hidden = k !== name; + } + if (name === "modules" && !mLoaded) pollModules(); + if (name === "logs" && !lLoaded) pollLogs(); + if (name === "vmMemory" && !vLoaded) pollVmMemory(); + } + tabs.processes.btn.addEventListener("click", () => activate("processes")); + tabs.modules.btn.addEventListener("click", () => activate("modules")); + tabs.logs.btn.addEventListener("click", () => activate("logs")); + tabs.vmMemory.btn.addEventListener("click", () => activate("vmMemory")); + window.addEventListener("popstate", () => activate(tabFromLocation(), "replace")); + + // ── processes panel ──────────────────────────────────────────── + const pBody = document.getElementById("p-rows"); + const pAll = document.getElementById("p-all"); + const pMine = document.getElementById("p-mine"); + const pExpand = document.getElementById("p-expand"); + const pCollapse = document.getElementById("p-collapse"); + + let pFilterMine = $("#p-mine").classList.contains("active"); + let pLastRows = []; + const pCollapsed = new Set(); + const pAutoCollapsed = new Set(); + const pSeenLarge = new Set(); + + function pSetFilter(mine) { + pFilterMine = mine; + pAll.classList.toggle("active", !mine); + pMine.classList.toggle("active", mine); + pRender(); + } + + pAll.addEventListener("click", () => pSetFilter(false)); + pMine.addEventListener("click", () => pSetFilter(true)); + pExpand.addEventListener("click", () => { + pCollapsed.clear(); + pAutoCollapsed.clear(); + for (const pid of pLargeGroupPids()) pSeenLarge.add(pid); + pRender(); + }); + pCollapse.addEventListener("click", () => { + const haveChildren = new Set(pLastRows.map(r => r.parent).filter(Boolean)); + const topPid = pPrimaryRootProcessPid(); + pCollapsed.clear(); + pAutoCollapsed.clear(); + for (const pid of haveChildren) { + if (pid !== topPid) pCollapsed.add(pid); + } + pRender(); + }); + + function pBuildTree(rows) { + const byPid = new Map(); + for (const r of rows) byPid.set(r.pid, Object.assign({}, r, {children: []})); + + const roots = []; + for (const node of byPid.values()) { + const parent = node.parent && byPid.get(node.parent); + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; + } + + function pRender() { + const rows = pFilterMine ? pLastRows.filter(r => r.mine) : pLastRows; + const tree = pBuildTree(rows); + const mineN = pLastRows.filter(r => r.mine).length; + + pAll.textContent = "all (" + pLastRows.length + ")"; + pMine.textContent = "mine (" + mineN + ")"; + + const frag = document.createDocumentFragment(); + for (const node of tree) pRenderNode(node, 0, frag); + pBody.replaceChildren(frag); + } + + function pRenderNode(node, depth, frag) { + const tr = document.createElement("tr"); + if (node.mine) tr.className = "mine"; + + const pidTd = document.createElement("td"); + pidTd.className = "pid-cell"; + pidTd.style.paddingLeft = (8 + depth * 16) + "px"; + + const disc = document.createElement("span"); + disc.className = "disclosure" + (node.children.length ? "" : " leaf"); + disc.textContent = + node.children.length === 0 ? "·" : + pCollapsed.has(node.pid) ? "▸" : "▾"; + if (node.children.length) { + disc.addEventListener("click", () => { + pSeenLarge.add(node.pid); + pAutoCollapsed.delete(node.pid); + if (pCollapsed.has(node.pid)) pCollapsed.delete(node.pid); + else pCollapsed.add(node.pid); + pRender(); + }); + } + pidTd.appendChild(disc); + pidTd.appendChild(document.createTextNode(" " + node.pid)); + tr.appendChild(pidTd); + + addCell(tr, node.name || "-", node.name ? "name" : ""); + addCell(tr, node.initial_call); + addCell(tr, node.memory_kb, "num"); + addCell(tr, node.tree_memory_kb || node.memory_kb, "num"); + addCell(tr, node.msgs, "num"); + addCell(tr, node.status); + frag.appendChild(tr); + + if (!pCollapsed.has(node.pid)) { + for (const child of node.children) pRenderNode(child, depth + 1, frag); + } + } + + function pollProcesses() { send({type: "list_processes"}); } + + handlers["processes"] = (msg) => { + pLastRows = msg.rows; + pAutoCollapseLargeGroups(); + pRender(); + }; + + function pLargeGroupPids() { + const large = new Set(); + const visit = (node) => { + if (node.children.length > 10) large.add(node.pid); + for (const child of node.children) visit(child); + }; + for (const node of pBuildTree(pLastRows)) visit(node); + return large; + } + + function pPrimaryRootProcessPid() { + const supervisor = pLastRows.find(r => r.name === "Forum.Supervisor"); + if (supervisor) return supervisor.pid; + + const roots = pBuildTree(pLastRows); + return roots.length ? roots[0].pid : null; + } + + function pAutoCollapseLargeGroups() { + const large = pLargeGroupPids(); + const topPid = pPrimaryRootProcessPid(); + + if (topPid) { + large.delete(topPid); + pCollapsed.delete(topPid); + pAutoCollapsed.delete(topPid); + pSeenLarge.add(topPid); + } + + for (const pid of pAutoCollapsed) { + if (!large.has(pid)) { + pCollapsed.delete(pid); + pAutoCollapsed.delete(pid); + } + } + + for (const pid of large) { + if (!pSeenLarge.has(pid)) { + pCollapsed.add(pid); + pAutoCollapsed.add(pid); + pSeenLarge.add(pid); + } + } + } + + // ── modules panel ────────────────────────────────────────────── + const mBody = document.getElementById("m-rows"); + const mAll = document.getElementById("m-all"); + const mMine = document.getElementById("m-mine"); + const mFilter = document.getElementById("m-filter"); + const mRefresh = document.getElementById("m-refresh"); + + let mFilterMine = $("#m-mine").classList.contains("active"); + let mNeedle = ""; + let mLastRows = []; + let mLoaded = false; + + function mSetFilter(mine) { + mFilterMine = mine; + mAll.classList.toggle("active", !mine); + mMine.classList.toggle("active", mine); + mRender(); + } + + mAll.addEventListener("click", () => mSetFilter(false)); + mMine.addEventListener("click", () => mSetFilter(true)); + mFilter.addEventListener("input", () => { mNeedle = mFilter.value.toLowerCase(); mRender(); }); + mRefresh.addEventListener("click", () => pollModules()); + + function mRender() { + let rows = mLastRows; + if (mFilterMine) rows = rows.filter(r => r.mine); + if (mNeedle) { + rows = rows.filter(r => + r.module.toLowerCase().includes(mNeedle) || + (r.source && r.source.toLowerCase().includes(mNeedle)) || + (r.app && r.app.toLowerCase().includes(mNeedle)) + ); + } + const mineN = mLastRows.filter(r => r.mine).length; + mAll.textContent = "all (" + mLastRows.length + ")"; + mMine.textContent = "mine (" + mineN + ")"; + + const frag = document.createDocumentFragment(); + for (const r of rows) { + const tr = document.createElement("tr"); + if (r.mine) tr.className = "mine"; + addCell(tr, r.module, "mod"); + addCell(tr, r.app || "-"); + addCell(tr, r.source || "(no source)", "src"); + frag.appendChild(tr); + } + mBody.replaceChildren(frag); + } + + function pollModules() { send({type: "list_modules"}); } + + handlers["modules"] = (msg) => { + mLastRows = msg.rows; + mLoaded = true; + mRender(); + }; + + // ── logs panel ───────────────────────────────────────────────── + const lBody = document.getElementById("l-rows"); + const lRefresh = document.getElementById("l-refresh"); + let lLoaded = false; + const chicagoTime = new Intl.DateTimeFormat("en-US", { + timeZone: "America/Chicago", + year: "numeric", + month: "short", + day: "2-digit", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short" + }); + + function pollLogs() { send({type: "list_logs"}); } + + function formatLogTime(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return chicagoTime.format(date); + } + + lRefresh.addEventListener("click", () => pollLogs()); + + handlers["logs"] = (msg) => { + lLoaded = true; + const frag = document.createDocumentFragment(); + for (const r of msg.rows) { + const tr = document.createElement("tr"); + addCell(tr, formatLogTime(r.time)); + addCell(tr, r.source_ip || ""); + addCell(tr, r.host || ""); + addCell(tr, r.method || ""); + addCell(tr, (r.path || "") + (r.query_string ? "?" + r.query_string : "")); + addCell(tr, r.status || "", "num"); + addCell(tr, r.duration_ms || "", "num"); + frag.appendChild(tr); + } + lBody.replaceChildren(frag); + }; + + // ── VM memory panel ──────────────────────────────────────────── + const vBody = document.getElementById("v-rows"); + const vRefresh = document.getElementById("v-refresh"); + let vLoaded = false; + + function pollVmMemory() { send({type: "list_vm_memory"}); } + + vRefresh.addEventListener("click", () => pollVmMemory()); + + handlers["vm_memory"] = (msg) => { + vLoaded = true; + const frag = document.createDocumentFragment(); + for (const r of msg.rows) { + const tr = document.createElement("tr"); + addCell(tr, r.category || ""); + addCell(tr, r.bytes || "", "num"); + addCell(tr, r.kb || "", "num"); + addCell(tr, r.mb || "", "num"); + frag.appendChild(tr); + } + vBody.replaceChildren(frag); + }; + + // ── boot ─────────────────────────────────────────────────────── + activate(tabFromLocation(), "replace"); + + ws.addEventListener("open", () => { + status.textContent = "connected — processes auto-refresh 5s"; + pollProcesses(); + if (active === "modules") pollModules(); + if (active === "logs") pollLogs(); + if (active === "vmMemory") pollVmMemory(); + // Processes keep polling whether or not the tab is visible — keeps + // the view fresh when the user switches back. Modules load on + // first activation and via the refresh button. + setInterval(pollProcesses, 5000); + }); + ws.addEventListener("close", () => { status.textContent = "disconnected"; }); + ws.addEventListener("error", () => { status.textContent = "error"; }); diff --git a/priv/ui/desktop.html b/priv/ui/desktop.html new file mode 100644 index 0000000..4565ee3 --- /dev/null +++ b/priv/ui/desktop.html @@ -0,0 +1,93 @@ + + + + + forum + + + +
connecting…
+
+ + + diff --git a/priv/ui/graphyellow.svg b/priv/ui/graphyellow.svg new file mode 100644 index 0000000..ee61958 --- /dev/null +++ b/priv/ui/graphyellow.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/priv/ui/pug-demo/README.md b/priv/ui/pug-demo/README.md new file mode 100644 index 0000000..560dac5 --- /dev/null +++ b/priv/ui/pug-demo/README.md @@ -0,0 +1,18 @@ +# Pug Features Demo + +This folder is a small Pug demo showing: + +- interpolation with `#{name}` +- conditionals with `if` and `case` +- loops with `each` +- includes with `_summary.pug` and `_mixins.pug` +- layout inheritance with `extends ./layout.pug` +- reusable mixins with `+featureCard(feature)` and `+badge(...)` + +Compile it with: + +```bash +npx pug-cli priv/ui/pug-demo/index.pug --pretty --out priv/ui/pug-demo +``` + +That writes `priv/ui/pug-demo/index.html`. diff --git a/priv/ui/pug-demo/_mixins.pug b/priv/ui/pug-demo/_mixins.pug new file mode 100644 index 0000000..b3894a9 --- /dev/null +++ b/priv/ui/pug-demo/_mixins.pug @@ -0,0 +1,8 @@ +mixin badge(label, tone) + span.badge(class=`badge--${tone}`)= label + +mixin featureCard(feature) + li.feature-card + h3 #{feature.name} + p.muted= feature.description + +badge(feature.statusLabel, feature.status) diff --git a/priv/ui/pug-demo/_summary.pug b/priv/ui/pug-demo/_summary.pug new file mode 100644 index 0000000..054581f --- /dev/null +++ b/priv/ui/pug-demo/_summary.pug @@ -0,0 +1,12 @@ +section.panel + h2 Included Summary + p + | This section comes from + code _summary.pug + | . It can still read variables declared in + code index.pug + | , including + strong #{name} + | and the feature count: + strong #{features.length} + | . diff --git a/priv/ui/pug-demo/index.html b/priv/ui/pug-demo/index.html new file mode 100644 index 0000000..a373f3b --- /dev/null +++ b/priv/ui/pug-demo/index.html @@ -0,0 +1,149 @@ + + + + + + Pug Features Demo + + + +
+
+

Pug features for Ada Lovelace

+

This page is rendered from index.pug and extends layout.pug.

+
+
+

Interpolation

+

Hello, Ada Lovelace. Your current plan is PRO.

+

The literal syntax is #{name}, which inserts escaped text into a line.

+
+
+

Conditionals

+

Welcome back, Ada Lovelace.

+

You are using the pro plan, so all examples are visible.

+
+
+

Loops and Mixins

+
    +
  • +

    Interpolation

    +

    Drop values into text with #{name} and escaped output.

    ready +
  • +
  • +

    Conditionals

    +

    Render different branches with if, else if, else, and case.

    ready +
  • +
  • +

    Loops

    +

    Repeat markup with each item in collection syntax.

    ready +
  • +
  • +

    Includes and extends

    +

    Compose pages from layouts and smaller partial files.

    practice +
  • +
  • +

    Mixins

    +

    Create reusable snippets that accept arguments.

    ready +
  • +
+
+
+

Included Summary

+

This section comes from _summary.pug. It can still read variables declared in index.pug, including Ada Lovelace and the feature count: 5.

+
+
+ + \ No newline at end of file diff --git a/priv/ui/pug-demo/index.pug b/priv/ui/pug-demo/index.pug new file mode 100644 index 0000000..cd0d0b0 --- /dev/null +++ b/priv/ui/pug-demo/index.pug @@ -0,0 +1,80 @@ +extends ./layout.pug +include ./_mixins.pug + +block content + - + const name = "Ada Lovelace"; + const signedIn = true; + const plan = "pro"; + const features = [ + { + name: "Interpolation", + description: "Drop values into text with #{name} and escaped output.", + status: "ready", + statusLabel: "ready" + }, + { + name: "Conditionals", + description: "Render different branches with if, else if, else, and case.", + status: "ready", + statusLabel: "ready" + }, + { + name: "Loops", + description: "Repeat markup with each item in collection syntax.", + status: "ready", + statusLabel: "ready" + }, + { + name: "Includes and extends", + description: "Compose pages from layouts and smaller partial files.", + status: "practice", + statusLabel: "practice" + }, + { + name: "Mixins", + description: "Create reusable snippets that accept arguments.", + status: "ready", + statusLabel: "ready" + } + ]; + + header + h1 Pug features for #{name} + p.muted + | This page is rendered from + code index.pug + | and extends + code layout.pug + | . + + section.panel + h2 Interpolation + p Hello, #{name}. Your current plan is #{plan.toUpperCase()}. + p + | The literal syntax is + code #{'#{name}'} + | , which inserts escaped text into a line. + + section.panel + h2 Conditionals + if signedIn + p Welcome back, #{name}. + else + p Please sign in to see the demo. + + case plan + when "free" + p You are using the free plan. + when "pro" + p You are using the pro plan, so all examples are visible. + default + p Your plan is #{plan}. + + section.panel + h2 Loops and Mixins + ul.feature-grid + each feature in features + +featureCard(feature) + + include ./_summary.pug diff --git a/priv/ui/pug-demo/layout.pug b/priv/ui/pug-demo/layout.pug new file mode 100644 index 0000000..b957120 --- /dev/null +++ b/priv/ui/pug-demo/layout.pug @@ -0,0 +1,102 @@ +doctype html +html(lang="en") + head + meta(charset="utf-8") + meta(name="viewport", content="width=device-width, initial-scale=1") + title Pug Features Demo + style. + :root { + color-scheme: light; + --ink: #202124; + --muted: #5f6368; + --line: #dadce0; + --paper: #ffffff; + --soft: #f7f8fa; + --accent: #0b57d0; + --accent-soft: #e8f0fe; + --good: #137333; + --warn: #b06000; + } + + * { box-sizing: border-box; } + + body { + margin: 0; + background: var(--soft); + color: var(--ink); + font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + + main { + width: min(960px, calc(100% - 32px)); + margin: 32px auto; + } + + header { + margin-bottom: 24px; + } + + h1, h2, h3, p { margin-top: 0; } + h1 { font-size: 32px; line-height: 1.1; } + h2 { font-size: 20px; margin-bottom: 12px; } + h3 { font-size: 16px; margin-bottom: 6px; } + + .muted { color: var(--muted); } + + .panel, .feature-card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 8px; + } + + .panel { + padding: 18px; + margin-bottom: 16px; + } + + .feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin: 0; + padding: 0; + list-style: none; + } + + .feature-card { + padding: 14px; + } + + .feature-card p { margin-bottom: 10px; } + + .badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 12px; + font-weight: 650; + } + + .badge--ready { + background: #e6f4ea; + color: var(--good); + } + + .badge--practice { + background: #fef7e0; + color: var(--warn); + } + + code { + background: #eef0f3; + border-radius: 4px; + padding: 1px 5px; + } + + body + main + block content diff --git a/prod.log b/prod.log new file mode 100644 index 0000000..64982e4 --- /dev/null +++ b/prod.log @@ -0,0 +1,360 @@ +09:57:48.975 [info] Running Forum.Router with Bandit 1.11.1 at 0.0.0.0:4000 (http) +09:57:48.986 [info] Running Forum.PublicSiteRouter with Bandit 1.11.1 at 0.0.0.0:4001 (http) +09:57:48.995 [info] Running Forum.PublicSiteRouter with Bandit 1.11.1 at 0.0.0.0:4002 (http) +09:57:56.404 [info] GET /admin/logs +09:57:56.412 [info] Sent 401 in 7ms +09:57:56.509 [info] GET /favicon.ico +09:57:56.509 [info] Sent 401 in 57µs +09:57:58.239 [info] GET /admin/logs +09:57:58.239 [info] Sent 401 in 41µs +09:57:58.318 [info] GET /favicon.ico +09:57:58.318 [info] Sent 401 in 46µs +09:57:59.325 [info] GET /admin/logs +09:57:59.325 [info] Sent 401 in 32µs +09:57:59.411 [info] GET /favicon.ico +09:57:59.411 [info] Sent 401 in 36µs +09:58:04.955 [info] GET / +09:58:05.079 [info] ws connected: user_id=4 +09:58:07.641 [info] GET /admin/logs +09:58:07.644 [info] Sent 200 in 2ms +09:58:07.817 [info] GET /admin/graphyellow.svg +09:58:07.819 [info] Sent 200 in 1ms +09:58:10.564 [info] GET /admin/logs +09:58:10.567 [info] Sent 200 in 3ms +09:58:10.721 [info] GET /admin/graphyellow.svg +09:58:10.722 [info] Sent 200 in 497µs +09:58:13.732 [info] Forum.Forms: reconciled, target=315 runtimes +09:58:22.343 [info] GET / +09:58:22.344 [info] ws connected: user_id=1 +09:58:35.023 [info] GET /admin/processes +09:58:35.024 [info] Sent 200 in 603µs +09:58:35.305 [info] GET /admin/graphyellow.svg +09:58:35.306 [info] Sent 200 in 419µs +09:58:35.418 [info] GET /admin/ws +09:58:35.419 [info] ws connected: user_id= +09:59:04.960 [info] ws disconnected: {:error, :closed} +09:59:10.133 [info] GET / +09:59:10.134 [info] ws connected: user_id=4 +09:59:16.426 [info] ws disconnected: :remote +09:59:22.344 [info] ws disconnected: {:error, :closed} +09:59:28.341 [info] GET / +09:59:28.342 [info] ws connected: user_id=1 +10:00:28.343 [info] ws disconnected: {:error, :closed} +10:00:34.355 [info] GET / +10:00:34.356 [info] ws connected: user_id=1 +10:01:34.359 [info] ws disconnected: {:error, :closed} +10:01:40.341 [info] GET / +10:01:40.342 [info] ws connected: user_id=1 +10:02:40.342 [info] ws disconnected: {:error, :closed} +10:02:46.344 [info] GET / +10:02:46.346 [info] ws connected: user_id=1 +10:03:46.345 [info] ws disconnected: {:error, :closed} +10:03:52.342 [info] GET / +10:03:52.343 [info] ws connected: user_id=1 +10:04:01.127 [info] GET /robots.txt +10:04:01.128 [info] Sent 404 in 64µs +10:04:01.207 [info] GET / +10:04:01.207 [info] Sent 404 in 73µs +10:04:52.343 [info] ws disconnected: {:error, :closed} +10:04:58.341 [info] GET / +10:04:58.342 [info] ws connected: user_id=1 +10:05:58.342 [info] ws disconnected: {:error, :closed} +10:06:04.333 [info] GET / +10:06:04.334 [info] ws connected: user_id=1 +10:07:04.341 [info] ws disconnected: {:error, :closed} +10:07:10.342 [info] GET / +10:07:10.342 [info] ws connected: user_id=1 +10:08:10.343 [info] ws disconnected: {:error, :closed} +10:08:16.336 [info] GET / +10:08:16.337 [info] ws connected: user_id=1 +10:08:41.142 [info] ws disconnected: {:error, :closed} +10:09:16.337 [info] ws disconnected: {:error, :closed} +10:09:22.340 [info] GET / +10:09:22.341 [info] ws connected: user_id=1 +10:10:22.341 [info] ws disconnected: {:error, :closed} +10:10:28.348 [info] GET / +10:10:28.349 [info] ws connected: user_id=1 +10:11:28.349 [info] ws disconnected: {:error, :closed} +10:11:34.352 [info] GET / +10:11:34.353 [info] ws connected: user_id=1 +10:12:34.357 [info] ws disconnected: {:error, :closed} +10:12:40.360 [info] GET / +10:12:40.360 [info] ws connected: user_id=1 +10:13:40.361 [info] ws disconnected: {:error, :closed} +10:13:46.358 [info] GET / +10:13:46.359 [info] ws connected: user_id=1 +10:14:46.360 [info] ws disconnected: {:error, :closed} +10:14:52.360 [info] GET / +10:14:52.361 [info] ws connected: user_id=1 +10:15:52.361 [info] ws disconnected: {:error, :closed} +10:15:58.364 [info] GET / +10:15:58.365 [info] ws connected: user_id=1 +10:16:58.364 [info] ws disconnected: {:error, :closed} +10:17:04.440 [info] GET / +10:17:04.441 [info] ws connected: user_id=1 +10:18:04.443 [info] ws disconnected: {:error, :closed} +10:18:10.363 [info] GET / +10:18:10.364 [info] ws connected: user_id=1 +10:19:10.365 [info] ws disconnected: {:error, :closed} +10:19:17.620 [info] GET / +10:19:17.621 [info] ws connected: user_id=1 +10:20:17.621 [info] ws disconnected: {:error, :closed} +10:20:23.371 [info] GET / +10:20:23.372 [info] ws connected: user_id=1 +10:21:23.378 [info] ws disconnected: {:error, :closed} +10:21:29.368 [info] GET / +10:21:29.369 [info] ws connected: user_id=1 +10:22:29.381 [info] ws disconnected: {:error, :closed} +10:22:35.387 [info] GET / +10:22:35.388 [info] ws connected: user_id=1 +10:23:35.405 [info] ws disconnected: {:error, :closed} +10:23:41.452 [info] GET / +10:23:41.453 [info] ws connected: user_id=1 +10:24:41.472 [info] ws disconnected: {:error, :closed} +10:24:47.382 [info] GET / +10:24:47.383 [info] ws connected: user_id=1 +10:25:47.382 [info] ws disconnected: {:error, :closed} +10:25:53.382 [info] GET / +10:25:53.383 [info] ws connected: user_id=1 +10:26:53.389 [info] ws disconnected: {:error, :closed} +10:26:59.380 [info] GET / +10:26:59.380 [info] ws connected: user_id=1 +10:27:59.392 [info] ws disconnected: {:error, :closed} +10:28:05.380 [info] GET / +10:28:05.381 [info] ws connected: user_id=1 +10:28:06.138 [info] GET /robots.txt +10:28:06.138 [info] Sent 404 in 79µs +10:29:05.398 [info] ws disconnected: {:error, :closed} +10:29:11.395 [info] GET / +10:29:11.395 [info] ws connected: user_id=1 +10:30:11.416 [info] ws disconnected: {:error, :closed} +10:30:17.386 [info] GET / +10:30:17.387 [info] ws connected: user_id=1 +10:31:17.386 [info] ws disconnected: {:error, :closed} +10:31:23.381 [info] GET / +10:31:23.381 [info] ws connected: user_id=1 +10:32:23.388 [info] ws disconnected: {:error, :closed} +10:32:29.378 [info] GET / +10:32:29.379 [info] ws connected: user_id=1 +10:33:29.391 [info] ws disconnected: {:error, :closed} +10:33:35.378 [info] GET / +10:33:35.379 [info] ws connected: user_id=1 +10:34:35.395 [info] ws disconnected: {:error, :closed} +10:34:41.386 [info] GET / +10:34:41.386 [info] ws connected: user_id=1 +10:35:41.387 [info] ws disconnected: {:error, :closed} +10:35:47.382 [info] GET / +10:35:47.382 [info] ws connected: user_id=1 +10:36:47.442 [info] ws disconnected: {:error, :closed} +10:36:53.382 [info] GET / +10:36:53.383 [info] ws connected: user_id=1 +10:37:53.420 [info] ws disconnected: {:error, :closed} +10:37:59.387 [info] GET / +10:37:59.387 [info] ws connected: user_id=1 +10:38:59.432 [info] ws disconnected: {:error, :closed} +10:39:05.387 [info] GET / +10:39:05.387 [info] ws connected: user_id=1 +10:40:05.420 [info] ws disconnected: {:error, :closed} +10:40:11.387 [info] GET / +10:40:11.387 [info] ws connected: user_id=1 +10:41:11.389 [info] ws disconnected: {:error, :closed} +10:41:17.389 [info] GET / +10:41:17.389 [info] ws connected: user_id=1 +10:42:17.390 [info] ws disconnected: {:error, :closed} +10:42:23.383 [info] GET / +10:42:23.384 [info] ws connected: user_id=1 +10:43:23.383 [info] ws disconnected: {:error, :closed} +10:43:29.391 [info] GET / +10:43:29.392 [info] ws connected: user_id=1 +10:44:29.393 [info] ws disconnected: {:error, :closed} +10:44:35.384 [info] GET / +10:44:35.384 [info] ws connected: user_id=1 +10:45:26.645 [info] GET /robots.txt +10:45:26.645 [info] Sent 404 in 111µs +10:45:35.392 [info] ws disconnected: {:error, :closed} +10:45:41.389 [info] GET / +10:45:41.389 [info] ws connected: user_id=1 +10:46:41.389 [info] ws disconnected: {:error, :closed} +10:46:47.382 [info] GET / +10:46:47.383 [info] ws connected: user_id=1 +10:47:47.388 [info] ws disconnected: {:error, :closed} +10:47:53.381 [info] GET / +10:47:53.382 [info] ws connected: user_id=1 +10:48:53.387 [info] ws disconnected: {:error, :closed} +10:48:59.378 [info] GET / +10:48:59.378 [info] ws connected: user_id=1 +10:49:59.379 [info] ws disconnected: {:error, :closed} +10:50:05.388 [info] GET / +10:50:05.389 [info] ws connected: user_id=1 +10:51:05.393 [info] ws disconnected: {:error, :closed} +10:51:11.385 [info] GET / +10:51:11.385 [info] ws connected: user_id=1 +10:52:11.390 [info] ws disconnected: {:error, :closed} +10:52:17.380 [info] GET / +10:52:17.380 [info] ws connected: user_id=1 +10:53:17.381 [info] ws disconnected: {:error, :closed} +10:53:23.384 [info] GET / +10:53:23.384 [info] ws connected: user_id=1 +10:54:23.385 [info] ws disconnected: {:error, :closed} +10:54:29.378 [info] GET / +10:54:29.378 [info] ws connected: user_id=1 +10:55:29.380 [info] ws disconnected: {:error, :closed} +10:55:35.382 [info] GET / +10:55:35.383 [info] ws connected: user_id=1 +10:56:35.382 [info] ws disconnected: {:error, :closed} +10:56:41.380 [info] GET / +10:56:41.381 [info] ws connected: user_id=1 +10:56:46.163 [info] GET / +10:56:46.163 [info] Sent 404 in 37µs +10:57:41.381 [info] ws disconnected: {:error, :closed} +10:57:47.373 [info] GET / +10:57:47.374 [info] ws connected: user_id=1 +10:58:47.375 [info] ws disconnected: {:error, :closed} +10:58:53.376 [info] GET / +10:58:53.377 [info] ws connected: user_id=1 +10:59:53.376 [info] ws disconnected: {:error, :closed} +10:59:59.382 [info] GET / +10:59:59.382 [info] ws connected: user_id=1 +11:00:59.384 [info] ws disconnected: {:error, :closed} +11:01:05.397 [info] GET / +11:01:05.398 [info] ws connected: user_id=1 +11:02:05.397 [info] ws disconnected: {:error, :closed} +11:02:11.378 [info] GET / +11:02:11.378 [info] ws connected: user_id=1 +11:03:11.382 [info] ws disconnected: {:error, :closed} +11:03:17.381 [info] GET / +11:03:17.382 [info] ws connected: user_id=1 +11:04:17.381 [info] ws disconnected: {:error, :closed} +11:04:23.378 [info] GET / +11:04:23.378 [info] ws connected: user_id=1 +11:05:23.379 [info] ws disconnected: {:error, :closed} +11:05:29.377 [info] GET / +11:05:29.378 [info] ws connected: user_id=1 +11:06:29.380 [info] ws disconnected: {:error, :closed} +11:06:35.380 [info] GET / +11:06:35.380 [info] ws connected: user_id=1 +11:07:35.385 [info] ws disconnected: {:error, :closed} +11:07:41.380 [info] GET / +11:07:41.381 [info] ws connected: user_id=1 +11:08:41.387 [info] ws disconnected: {:error, :closed} +11:08:47.378 [info] GET / +11:08:47.378 [info] ws connected: user_id=1 +11:09:47.407 [info] ws disconnected: {:error, :closed} +11:09:53.377 [info] GET / +11:09:53.377 [info] ws connected: user_id=1 +11:10:53.382 [info] ws disconnected: {:error, :closed} +11:10:59.394 [info] GET / +11:10:59.395 [info] ws connected: user_id=1 +11:11:59.405 [info] ws disconnected: {:error, :closed} +11:12:05.378 [info] GET / +11:12:05.378 [info] ws connected: user_id=1 +11:13:05.395 [info] ws disconnected: {:error, :closed} +11:13:11.375 [info] GET / +11:13:11.376 [info] ws connected: user_id=1 +11:14:11.385 [info] ws disconnected: {:error, :closed} +11:14:17.378 [info] GET / +11:14:17.379 [info] ws connected: user_id=1 +11:15:17.435 [info] ws disconnected: {:error, :closed} +11:15:23.383 [info] GET / +11:15:23.383 [info] ws connected: user_id=1 +11:16:23.442 [info] ws disconnected: {:error, :closed} +11:16:29.386 [info] GET / +11:16:29.387 [info] ws connected: user_id=1 +11:17:29.436 [info] ws disconnected: {:error, :closed} +11:17:35.379 [info] GET / +11:17:35.380 [info] ws connected: user_id=1 +11:18:35.439 [info] ws disconnected: {:error, :closed} +11:18:41.376 [info] GET / +11:18:41.376 [info] ws connected: user_id=1 +11:19:41.410 [info] ws disconnected: {:error, :closed} +11:19:47.373 [info] GET / +11:19:47.374 [info] ws connected: user_id=1 +11:20:47.380 [info] ws disconnected: {:error, :closed} +11:20:53.364 [info] GET / +11:20:53.365 [info] ws connected: user_id=1 +11:21:53.366 [info] ws disconnected: {:error, :closed} +11:21:59.358 [info] GET / +11:21:59.359 [info] ws connected: user_id=1 +11:22:59.359 [info] ws disconnected: {:error, :closed} +11:23:05.365 [info] GET / +11:23:05.366 [info] ws connected: user_id=1 +11:24:05.366 [info] ws disconnected: {:error, :closed} +11:24:11.359 [info] GET / +11:24:11.360 [info] ws connected: user_id=1 +11:25:11.380 [info] ws disconnected: {:error, :closed} +11:25:17.356 [info] GET / +11:25:17.357 [info] ws connected: user_id=1 +11:26:17.380 [info] ws disconnected: {:error, :closed} +11:26:23.353 [info] GET / +11:26:23.354 [info] ws connected: user_id=1 +11:27:23.357 [info] ws disconnected: {:error, :closed} +11:27:29.346 [info] GET / +11:27:29.347 [info] ws connected: user_id=1 +11:28:29.356 [info] ws disconnected: {:error, :closed} +11:28:35.349 [info] GET / +11:28:35.350 [info] ws connected: user_id=1 +11:29:35.366 [info] ws disconnected: {:error, :closed} +11:29:41.357 [info] GET / +11:29:41.358 [info] ws connected: user_id=1 +11:30:41.380 [info] ws disconnected: {:error, :closed} +11:30:47.343 [info] GET / +11:30:47.344 [info] ws connected: user_id=1 +11:31:06.936 [info] GET / +11:31:06.936 [info] Sent 404 in 64µs +11:31:47.354 [info] ws disconnected: {:error, :closed} +11:31:53.355 [info] GET / +11:31:53.356 [info] ws connected: user_id=1 +11:32:53.359 [info] ws disconnected: {:error, :closed} +11:32:59.340 [info] GET / +11:32:59.340 [info] ws connected: user_id=1 +11:33:59.349 [info] ws disconnected: {:error, :closed} +11:34:05.342 [info] GET / +11:34:05.343 [info] ws connected: user_id=1 +11:35:05.348 [info] ws disconnected: {:error, :closed} +11:35:11.329 [info] GET / +11:35:11.330 [info] ws connected: user_id=1 +11:36:11.330 [info] ws disconnected: {:error, :closed} +11:36:17.343 [info] GET / +11:36:17.344 [info] ws connected: user_id=1 +11:37:17.343 [info] ws disconnected: {:error, :closed} +11:37:23.335 [info] GET / +11:37:23.336 [info] ws connected: user_id=1 +11:38:23.336 [info] ws disconnected: {:error, :closed} +11:38:29.336 [info] GET / +11:38:29.336 [info] ws connected: user_id=1 +11:39:29.337 [info] ws disconnected: {:error, :closed} +11:39:35.334 [info] GET / +11:39:35.335 [info] ws connected: user_id=1 +11:40:35.336 [info] ws disconnected: {:error, :closed} +11:40:41.333 [info] GET / +11:40:41.334 [info] ws connected: user_id=1 +11:41:41.333 [info] ws disconnected: {:error, :closed} +11:41:47.324 [info] GET / +11:41:47.324 [info] ws connected: user_id=1 +11:42:47.324 [info] ws disconnected: {:error, :closed} +11:42:53.332 [info] GET / +11:42:53.333 [info] ws connected: user_id=1 +11:43:53.332 [info] ws disconnected: {:error, :closed} +11:43:59.330 [info] GET / +11:43:59.330 [info] ws connected: user_id=1 +11:44:59.332 [info] ws disconnected: {:error, :closed} +11:45:05.329 [info] GET / +11:45:05.330 [info] ws connected: user_id=1 +11:46:05.330 [info] ws disconnected: {:error, :closed} +11:46:11.329 [info] GET / +11:46:11.330 [info] ws connected: user_id=1 +11:47:11.330 [info] ws disconnected: {:error, :closed} +11:47:17.360 [info] GET / +11:47:17.360 [info] ws connected: user_id=1 +11:48:17.385 [info] ws disconnected: {:error, :closed} +11:48:23.328 [info] GET / +11:48:23.329 [info] ws connected: user_id=1 +11:49:23.332 [info] ws disconnected: {:error, :closed} +11:49:29.324 [info] GET / +11:49:29.325 [info] ws connected: user_id=1 +11:50:29.334 [info] ws disconnected: {:error, :closed} +11:50:35.347 [info] GET / +11:50:35.348 [info] ws connected: user_id=1 +11:51:35.363 [info] ws disconnected: {:error, :closed} +11:51:41.328 [info] GET / +11:51:41.329 [info] ws connected: user_id=1 diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..0c813fd --- /dev/null +++ b/restart.sh @@ -0,0 +1,8 @@ +set -e + +lsof -ti:4000 | xargs kill +elixir --name a@10.0.0.1 --cookie supersecret --erl "-detached -kernel inet_dist_listen_min 9100 inet_dist_listen_max 9100" -S mix run --no-halt + +sleep 2 + +iex --name console@localhost --cookie supersecret --remsh a@10.0.0.1 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..55ce622 --- /dev/null +++ b/start.sh @@ -0,0 +1 @@ +MIX_ENV=prod elixir --name a@10.0.0.1 --cookie lookatthepreviousweekend --erl "-detached -kernel inet_dist_listen_min 9100 inet_dist_listen_max 9100" -S mix run --no-halt diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..4fbfd06 --- /dev/null +++ b/stop.sh @@ -0,0 +1 @@ +lsof -ti:4000 | xargs kill \ No newline at end of file diff --git a/terminal.sh b/terminal.sh new file mode 100755 index 0000000..55dbaef --- /dev/null +++ b/terminal.sh @@ -0,0 +1 @@ +iex --name console@localhost --cookie lookatthepreviousweekend --remsh a@10.0.0.1 \ No newline at end of file