init
9
.devcontainer/devcontainer-lock.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.formatter.exs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
||||||
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/_build/
|
||||||
|
/deps/
|
||||||
|
erl_crash.dump
|
||||||
|
*.ez
|
||||||
|
.elixir_ls/
|
||||||
|
priv/logs
|
||||||
49
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
54
admin_htmx.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>admin</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<style>
|
||||||
|
body { font: 12px ui-monospace, monospace; margin: 1rem; }
|
||||||
|
.tabs { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||||
|
.tabs button {
|
||||||
|
color: #06c; background: none; border: none; font: inherit;
|
||||||
|
cursor: pointer; padding: 4px 10px; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.tabs button.active { font-weight: bold; color: #000; background: #f0f0f0; cursor: default; }
|
||||||
|
.toolbar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
|
||||||
|
.toolbar input[type="text"] {
|
||||||
|
font: inherit; padding: 2px 6px; border: 1px solid #ccc;
|
||||||
|
border-radius: 3px; width: 16rem;
|
||||||
|
}
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { padding: 4px 8px; border-bottom: 1px solid #eee; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #f7f7f7; position: sticky; top: 0; }
|
||||||
|
tr:hover { background: #fafafa; }
|
||||||
|
.num { text-align: right; }
|
||||||
|
.mine { background: #fffbe6; }
|
||||||
|
.name, .mod { color: #06c; }
|
||||||
|
.src { color: #555; }
|
||||||
|
.htmx-indicator { color: #888; opacity: 0; transition: opacity 200ms; }
|
||||||
|
.htmx-request .htmx-indicator { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="active"
|
||||||
|
onclick="activateTab(this)"
|
||||||
|
hx-get="/admin_htmx/processes" hx-target="#panel"
|
||||||
|
hx-trigger="click, load">processes</button>
|
||||||
|
<button onclick="activateTab(this)"
|
||||||
|
hx-get="/admin_htmx/modules" hx-target="#panel"
|
||||||
|
hx-trigger="click">modules</button>
|
||||||
|
<span class="htmx-indicator">…</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="panel"></main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function activateTab(btn) {
|
||||||
|
for (const b of btn.parentNode.querySelectorAll("button")) {
|
||||||
|
b.classList.toggle("active", b === btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
config/runtime.exs
Normal file
@@ -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
|
||||||
126
lib/forum/admin.ex
Normal file
@@ -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
|
||||||
|
"""
|
||||||
|
<section>
|
||||||
|
<form class="toolbar"
|
||||||
|
hx-get="/admin_htmx/processes/rows"
|
||||||
|
hx-target="#p-rows"
|
||||||
|
hx-trigger="change">
|
||||||
|
<label><input type="checkbox" name="mine"> mine</label>
|
||||||
|
<span class="htmx-indicator">…</span>
|
||||||
|
</form>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>pid</th><th>name</th><th>initial call</th>
|
||||||
|
<th class="num">memory (KB)</th><th class="num">msgs</th><th>status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="p-rows"
|
||||||
|
hx-get="/admin_htmx/processes/rows"
|
||||||
|
hx-trigger="load, every 5s"
|
||||||
|
hx-include="closest section form"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
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|<tr#{row_cls}>|,
|
||||||
|
~s|<td style="padding-left:#{pad}px">#{h(node["pid"])}</td>|,
|
||||||
|
~s|<td#{name_cls}>#{h(name || "-")}</td>|,
|
||||||
|
~s|<td>#{h(node["initial_call"])}</td>|,
|
||||||
|
~s|<td class="num">#{node["memory_kb"]}</td>|,
|
||||||
|
~s|<td class="num">#{node["msgs"]}</td>|,
|
||||||
|
~s|<td>#{h(node["status"])}</td>|,
|
||||||
|
"</tr>"
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── modules panel ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def modules_panel do
|
||||||
|
"""
|
||||||
|
<section>
|
||||||
|
<form class="toolbar"
|
||||||
|
hx-get="/admin_htmx/modules/rows"
|
||||||
|
hx-target="#m-rows"
|
||||||
|
hx-trigger="change, input changed delay:200ms from:input[name=q]">
|
||||||
|
<label><input type="checkbox" name="mine"> mine</label>
|
||||||
|
<input type="text" name="q" placeholder="filter…" autocomplete="off">
|
||||||
|
<span class="htmx-indicator">…</span>
|
||||||
|
</form>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>module</th><th>app</th><th>source</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="m-rows"
|
||||||
|
hx-get="/admin_htmx/modules/rows"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-include="closest section form"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
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|<tr#{row_cls}>| <>
|
||||||
|
~s|<td class="mod">#{h(r["module"])}</td>| <>
|
||||||
|
~s|<td>#{h(r["app"] || "-")}</td>| <>
|
||||||
|
~s|<td class="src">#{h(r["source"] || "(no source)")}</td>| <>
|
||||||
|
"</tr>"
|
||||||
|
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
|
||||||
72
lib/forum/application.ex
Normal file
@@ -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
|
||||||
|
# <forms-> 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
|
||||||
23
lib/forum/assets.ex
Normal file
@@ -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
|
||||||
44
lib/forum/db.ex
Normal file
@@ -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
|
||||||
300
lib/forum/forms.ex
Normal file
@@ -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 <forms-> 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))
|
||||||
|
|
||||||
|
"""
|
||||||
|
<section class="form-table" data-form-tag="#{escape_attr(tag_name)}">
|
||||||
|
<h2><code><#{escape_html(tag_name)}-></code></h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
#{header}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
#{rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|> String.trim()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_header(column) do
|
||||||
|
" <th>#{escape_html(column)}</th>"
|
||||||
|
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, "")
|
||||||
|
" <td>#{escape_html(value)}</td>"
|
||||||
|
end)
|
||||||
|
|
||||||
|
"""
|
||||||
|
<tr>
|
||||||
|
#{cells}
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|> 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
|
||||||
108
lib/forum/forms/watcher.ex
Normal file
@@ -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
|
||||||
61
lib/forum/log_store.ex
Normal file
@@ -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
|
||||||
44
lib/forum/modules.ex
Normal file
@@ -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
|
||||||
161
lib/forum/network.ex
Normal file
@@ -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 `/<network>` use the
|
||||||
|
matching network forms from `priv/networks/<network>/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("<!-- FORMS_HTML -->", 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("<!-- FORMS_HTML -->", 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
|
||||||
56
lib/forum/network_supervisor.ex
Normal file
@@ -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
|
||||||
175
lib/forum/processes.ex
Normal file
@@ -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
|
||||||
182
lib/forum/public_site.ex
Normal file
@@ -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
|
||||||
60
lib/forum/public_site_router.ex
Normal file
@@ -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
|
||||||
48
lib/forum/public_site_supervisor.ex
Normal file
@@ -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
|
||||||
125
lib/forum/router.ex
Normal file
@@ -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
|
||||||
24
lib/forum/vm_memory.ex
Normal file
@@ -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
|
||||||
110
lib/forum/ws_handler.ex
Normal file
@@ -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
|
||||||
33
mix.exs
Normal file
@@ -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
|
||||||
27
mix.lock
Normal file
@@ -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"},
|
||||||
|
}
|
||||||
319
priv/experiments/forms.pug
Normal file
@@ -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")
|
||||||
336
priv/experiments/forms.sql
Normal file
@@ -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);
|
||||||
72
priv/forms.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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"></member->
|
||||||
|
<app- id="1" name="settings"></app->
|
||||||
|
<app- id="2" name="people"></app->
|
||||||
|
<app- id="3" name="calendar"></app->
|
||||||
|
<app- id="4" name="treasury"></app->
|
||||||
|
<app- id="5" name="politics"></app->
|
||||||
|
<app- id="6" name="files"></app->
|
||||||
|
<app- id="7" name="jobs"></app->
|
||||||
|
<app- id="8" name="tasks"></app->
|
||||||
|
<app- id="9" name="chat"></app->
|
||||||
|
<app- id="10" name="announcements"></app->
|
||||||
|
<permission- id="1" key="events.add" app-id="3" description="Can add events"></permission->
|
||||||
|
<permission- id="2" key="events.delete" app-id="3" description="Can delete events"></permission->
|
||||||
|
<permission- id="3" key="events.edit" app-id="3" description="Can edit events"></permission->
|
||||||
|
<permission- id="4" key="events.get" app-id="3" description="Can view events"></permission->
|
||||||
|
<permission- id="5" key="jobs.add" app-id="7" description="Can add jobs"></permission->
|
||||||
|
<permission- id="6" key="jobs.delete" app-id="7" description="Can delete jobs"></permission->
|
||||||
|
<permission- id="7" key="jobs.edit" app-id="7" description="Can edit jobs"></permission->
|
||||||
|
<permission- id="8" key="jobs.get" app-id="7" description="Can view jobs"></permission->
|
||||||
|
<permission- id="9" key="announcements.add" app-id="10" description="Can add announcements"></permission->
|
||||||
|
<permission- id="10" key="announcements.delete" app-id="10" description="Can delete announcements"></permission->
|
||||||
|
<permission- id="11" key="announcements.edit" app-id="10" description="Can edit announcements"></permission->
|
||||||
|
<permission- id="12" key="announcements.get" app-id="10" description="Can view announcements"></permission->
|
||||||
|
<permission- id="13" key="role_apps.edit" app-id="1" description="Can edit role apps"></permission->
|
||||||
|
<permission- id="14" key="roles.create" app-id="1" description="Can create roles"></permission->
|
||||||
|
<permission- id="15" key="roles.delete" app-id="1" description="Can delete roles"></permission->
|
||||||
|
<permission- id="16" key="role_notifications.edit" app-id="1" description="Can edit role notifications"></permission->
|
||||||
|
<permission- id="17" key="chats.create" app-id="9" description="Can create chats"></permission->
|
||||||
|
<permission- id="18" key="chats.edit" app-id="9" description="Can edit chats"></permission->
|
||||||
|
<permission- id="19" key="chats.delete" app-id="9" description="Can delete chats"></permission->
|
||||||
|
<permission- id="20" key="chats_message.send" app-id="9" description="Can send messages"></permission->
|
||||||
|
<permission- id="21" key="chats_message.edit" app-id="9" description="Can edit messages"></permission->
|
||||||
|
<permission- id="22" key="chats_message.delete" app-id="9" description="Can delete messages"></permission->
|
||||||
|
<member-app- id="1" member-id="1" app-id="1"></member-app->
|
||||||
|
<member-app- id="2" member-id="4" app-id="1"></member-app->
|
||||||
|
<member-app- id="3" member-id="6" app-id="1"></member-app->
|
||||||
|
<member-app- id="4" member-id="1" app-id="2"></member-app->
|
||||||
|
<member-app- id="5" member-id="4" app-id="2"></member-app->
|
||||||
|
<member-app- id="6" member-id="6" app-id="2"></member-app->
|
||||||
|
<member-app- id="7" member-id="1" app-id="3"></member-app->
|
||||||
|
<member-app- id="8" member-id="4" app-id="3"></member-app->
|
||||||
|
<member-app- id="9" member-id="6" app-id="3"></member-app->
|
||||||
|
<member-app- id="10" member-id="1" app-id="4"></member-app->
|
||||||
|
<member-app- id="11" member-id="4" app-id="4"></member-app->
|
||||||
|
<member-app- id="12" member-id="6" app-id="4"></member-app->
|
||||||
|
<member-app- id="13" member-id="1" app-id="5"></member-app->
|
||||||
|
<member-app- id="14" member-id="4" app-id="5"></member-app->
|
||||||
|
<member-app- id="15" member-id="6" app-id="5"></member-app->
|
||||||
|
<member-app- id="16" member-id="1" app-id="6"></member-app->
|
||||||
|
<member-app- id="17" member-id="4" app-id="6"></member-app->
|
||||||
|
<member-app- id="18" member-id="6" app-id="6"></member-app->
|
||||||
|
<member-app- id="19" member-id="1" app-id="7"></member-app->
|
||||||
|
<member-app- id="20" member-id="4" app-id="7"></member-app->
|
||||||
|
<member-app- id="21" member-id="6" app-id="7"></member-app->
|
||||||
|
<member-app- id="22" member-id="1" app-id="8"></member-app->
|
||||||
|
<member-app- id="23" member-id="4" app-id="8"></member-app->
|
||||||
|
<member-app- id="24" member-id="6" app-id="8"></member-app->
|
||||||
|
<member-app- id="25" member-id="1" app-id="9"></member-app->
|
||||||
|
<member-app- id="26" member-id="4" app-id="10"></member-app->
|
||||||
|
<member-app- id="27" member-id="6" app-id="9"></member-app->
|
||||||
|
|
||||||
|
</forms->
|
||||||
15
priv/js/form_init.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
264
priv/networks/comalyr/index.html
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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"></member->
|
||||||
|
<app- id="1" name="settings"></app->
|
||||||
|
<app- id="2" name="people"></app->
|
||||||
|
<app- id="3" name="calendar"></app->
|
||||||
|
<app- id="4" name="treasury"></app->
|
||||||
|
<app- id="5" name="politics"></app->
|
||||||
|
<app- id="6" name="files"></app->
|
||||||
|
<app- id="7" name="jobs"></app->
|
||||||
|
<app- id="8" name="tasks"></app->
|
||||||
|
<app- id="9" name="chat"></app->
|
||||||
|
<app- id="10" name="announcements"></app->
|
||||||
|
<permission- id="1" key="events.add" app-id="3" description="Can add events"></permission->
|
||||||
|
<permission- id="2" key="events.delete" app-id="3" description="Can delete events"></permission->
|
||||||
|
<permission- id="3" key="events.edit" app-id="3" description="Can edit events"></permission->
|
||||||
|
<permission- id="4" key="events.get" app-id="3" description="Can view events"></permission->
|
||||||
|
<permission- id="5" key="jobs.add" app-id="7" description="Can add jobs"></permission->
|
||||||
|
<permission- id="6" key="jobs.delete" app-id="7" description="Can delete jobs"></permission->
|
||||||
|
<permission- id="7" key="jobs.edit" app-id="7" description="Can edit jobs"></permission->
|
||||||
|
<permission- id="8" key="jobs.get" app-id="7" description="Can view jobs"></permission->
|
||||||
|
<permission- id="9" key="announcements.add" app-id="10" description="Can add announcements"></permission->
|
||||||
|
<permission- id="10" key="announcements.delete" app-id="10" description="Can delete announcements"></permission->
|
||||||
|
<permission- id="11" key="announcements.edit" app-id="10" description="Can edit announcements"></permission->
|
||||||
|
<permission- id="12" key="announcements.get" app-id="10" description="Can view announcements"></permission->
|
||||||
|
<permission- id="13" key="role_apps.edit" app-id="1" description="Can edit role apps"></permission->
|
||||||
|
<permission- id="14" key="roles.create" app-id="1" description="Can create roles"></permission->
|
||||||
|
<permission- id="15" key="roles.delete" app-id="1" description="Can delete roles"></permission->
|
||||||
|
<permission- id="16" key="role_notifications.edit" app-id="1" description="Can edit role notifications"></permission->
|
||||||
|
<permission- id="17" key="chats.create" app-id="9" description="Can create chats"></permission->
|
||||||
|
<permission- id="18" key="chats.edit" app-id="9" description="Can edit chats"></permission->
|
||||||
|
<permission- id="19" key="chats.delete" app-id="9" description="Can delete chats"></permission->
|
||||||
|
<permission- id="20" key="chats_message.send" app-id="9" description="Can send messages"></permission->
|
||||||
|
<permission- id="21" key="chats_message.edit" app-id="9" description="Can edit messages"></permission->
|
||||||
|
<permission- id="22" key="chats_message.delete" app-id="9" description="Can delete messages"></permission->
|
||||||
|
<member-app- id="1" member-id="1" app-id="1"></member-app->
|
||||||
|
<member-app- id="2" member-id="4" app-id="1"></member-app->
|
||||||
|
<member-app- id="3" member-id="6" app-id="1"></member-app->
|
||||||
|
<member-app- id="4" member-id="1" app-id="2"></member-app->
|
||||||
|
<member-app- id="5" member-id="4" app-id="2"></member-app->
|
||||||
|
<member-app- id="6" member-id="6" app-id="2"></member-app->
|
||||||
|
<member-app- id="7" member-id="1" app-id="3"></member-app->
|
||||||
|
<member-app- id="8" member-id="4" app-id="3"></member-app->
|
||||||
|
<member-app- id="9" member-id="6" app-id="3"></member-app->
|
||||||
|
<member-app- id="10" member-id="1" app-id="4"></member-app->
|
||||||
|
<member-app- id="11" member-id="4" app-id="4"></member-app->
|
||||||
|
<member-app- id="12" member-id="6" app-id="4"></member-app->
|
||||||
|
<member-app- id="13" member-id="1" app-id="5"></member-app->
|
||||||
|
<member-app- id="14" member-id="4" app-id="5"></member-app->
|
||||||
|
<member-app- id="15" member-id="6" app-id="5"></member-app->
|
||||||
|
<member-app- id="16" member-id="1" app-id="6"></member-app->
|
||||||
|
<member-app- id="17" member-id="4" app-id="6"></member-app->
|
||||||
|
<member-app- id="18" member-id="6" app-id="6"></member-app->
|
||||||
|
<member-app- id="19" member-id="1" app-id="7"></member-app->
|
||||||
|
<member-app- id="20" member-id="4" app-id="7"></member-app->
|
||||||
|
<member-app- id="21" member-id="6" app-id="7"></member-app->
|
||||||
|
<member-app- id="22" member-id="1" app-id="8"></member-app->
|
||||||
|
<member-app- id="23" member-id="4" app-id="8"></member-app->
|
||||||
|
<member-app- id="24" member-id="6" app-id="8"></member-app->
|
||||||
|
<member-app- id="25" member-id="1" app-id="9"></member-app->
|
||||||
|
<member-app- id="26" member-id="4" app-id="10"></member-app->
|
||||||
|
<member-app- id="27" member-id="6" app-id="9"></member-app->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- network: comalyr -->
|
||||||
|
<!-- 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"></network->
|
||||||
|
<join-code- id="2" code="hyperia" network-id="2"></join-code->
|
||||||
|
<member-network- id="4" member-id="1" network-id="2" created="2026-01-13 13:28:35.0701"></member-network->
|
||||||
|
<member-network- id="5" member-id="4" network-id="2" created="2026-03-15 13:28:35.0701"></member-network->
|
||||||
|
<member-network- id="6" member-id="6" network-id="2" created="2026-02-01 09:00:00+00"></member-network->
|
||||||
|
<member-network- id="7" member-id="7" network-id="2" created="2026-02-01 09:00:00+00"></member-network->
|
||||||
|
<member-network- id="8" member-id="8" network-id="2" created="2026-02-01 09:00:00+00"></member-network->
|
||||||
|
<member-network- id="9" member-id="9" network-id="2" created="2026-02-01 09:00:00+00"></member-network->
|
||||||
|
<role- id="3" network-id="2" name="admin" is-default="false"></role->
|
||||||
|
<role- id="4" network-id="2" name="member" is-default="true"></role->
|
||||||
|
<role-app- id="11" role-id="3" app-id="1" network-id="2"></role-app->
|
||||||
|
<role-app- id="12" role-id="3" app-id="2" network-id="2"></role-app->
|
||||||
|
<role-app- id="13" role-id="3" app-id="3" network-id="2"></role-app->
|
||||||
|
<role-app- id="14" role-id="3" app-id="4" network-id="2"></role-app->
|
||||||
|
<role-app- id="15" role-id="3" app-id="5" network-id="2"></role-app->
|
||||||
|
<role-app- id="16" role-id="3" app-id="6" network-id="2"></role-app->
|
||||||
|
<role-app- id="17" role-id="3" app-id="7" network-id="2"></role-app->
|
||||||
|
<role-app- id="18" role-id="3" app-id="8" network-id="2"></role-app->
|
||||||
|
<role-app- id="19" role-id="3" app-id="9" network-id="2"></role-app->
|
||||||
|
<role-app- id="20" role-id="3" app-id="10" network-id="2"></role-app->
|
||||||
|
<role-permission- id="29" role-id="3" permission-key="events.get" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="30" role-id="3" permission-key="jobs.get" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="31" role-id="3" permission-key="announcements.get" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="32" role-id="3" permission-key="events.add" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="33" role-id="3" permission-key="events.delete" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="34" role-id="3" permission-key="events.edit" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="35" role-id="3" permission-key="jobs.add" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="36" role-id="3" permission-key="jobs.delete" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="37" role-id="3" permission-key="jobs.edit" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="38" role-id="3" permission-key="announcements.add" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="39" role-id="3" permission-key="announcements.delete" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="40" role-id="3" permission-key="announcements.edit" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="41" role-id="3" permission-key="role_apps.edit" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="42" role-id="3" permission-key="roles.create" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="43" role-id="3" permission-key="roles.delete" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="44" role-id="3" permission-key="chats.create" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="45" role-id="3" permission-key="chats.edit" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="46" role-id="3" permission-key="chats.delete" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="47" role-id="3" permission-key="chats_message.send" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="48" role-id="3" permission-key="chats_message.edit" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="49" role-id="3" permission-key="chats_message.delete" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="50" role-id="3" permission-key="role_notifications.edit" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="51" role-id="4" permission-key="events.get" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="52" role-id="4" permission-key="jobs.get" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="53" role-id="4" permission-key="announcements.get" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="54" role-id="4" permission-key="events.add" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="55" role-id="4" permission-key="chats.create" network-id="2"></role-permission->
|
||||||
|
<role-permission- id="56" role-id="4" permission-key="chats_message.send" network-id="2"></role-permission->
|
||||||
|
<member-role- id="4" member-id="1" role-id="3" granted-by="1" network-id="2"></member-role->
|
||||||
|
<member-role- id="5" member-id="4" role-id="3" granted-by="1" network-id="2"></member-role->
|
||||||
|
<member-role- id="6" member-id="6" role-id="3" granted-by="1" network-id="2"></member-role->
|
||||||
|
<member-role- id="7" member-id="6" role-id="4" granted-by="1" network-id="2"></member-role->
|
||||||
|
<member-role- id="8" member-id="7" role-id="4" granted-by="1" network-id="2"></member-role->
|
||||||
|
<member-role- id="9" member-id="8" role-id="4" granted-by="1" network-id="2"></member-role->
|
||||||
|
<member-role- id="10" member-id="9" role-id="4" granted-by="1" network-id="2"></member-role->
|
||||||
|
<calendar- schema="events" id="1" network-id="2" owner-id="1" name="Main Calendar" description="The main calendar for the network" color="#9E1C29"></calendar->
|
||||||
|
<calendar- schema="events" id="2" network-id="2" owner-id="1" name="Sub-Calendar" description="Sub-calendar for the network" color="#3D6FAD"></calendar->
|
||||||
|
<calendar- schema="events" id="3" network-id="2" owner-id="1" name="Sub-Calendar 2" description="Another sub-calendar for the network" color="#2A8636"></calendar->
|
||||||
|
<event-recurrence- schema="events" id="1" frequency="weekly" interval="1" days-of-week="{2}" network-id="2"></event-recurrence->
|
||||||
|
<event-recurrence- schema="events" id="2" frequency="weekly" interval="2" days-of-week="{3}" count="10" network-id="2"></event-recurrence->
|
||||||
|
<event-recurrence- schema="events" id="3" frequency="weekly" interval="1" days-of-week="{1,3,5}" network-id="2"></event-recurrence->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<event-calendar- schema="events" id="1" event-id="1" calendar-id="1" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="2" event-id="2" calendar-id="2" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="3" event-id="3" calendar-id="1" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="4" event-id="3" calendar-id="3" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="5" event-id="4" calendar-id="2" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="6" event-id="5" calendar-id="2" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="7" event-id="6" calendar-id="1" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="8" event-id="6" calendar-id="2" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="9" event-id="7" calendar-id="3" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="10" event-id="8" calendar-id="2" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="11" event-id="8" calendar-id="3" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="12" event-id="9" calendar-id="3" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="13" event-id="10" calendar-id="3" network-id="2"></event-calendar->
|
||||||
|
<event-calendar- schema="events" id="14" event-id="11" calendar-id="3" network-id="2"></event-calendar->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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"></job->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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"></announcement->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<chat-member- schema="chats" id="28" chat-id="8" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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"></message->
|
||||||
|
</forms->
|
||||||
134
priv/networks/cs/index.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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"></member->
|
||||||
|
<app- id="1" name="settings"></app->
|
||||||
|
<app- id="2" name="people"></app->
|
||||||
|
<app- id="3" name="calendar"></app->
|
||||||
|
<app- id="4" name="treasury"></app->
|
||||||
|
<app- id="5" name="politics"></app->
|
||||||
|
<app- id="6" name="files"></app->
|
||||||
|
<app- id="7" name="jobs"></app->
|
||||||
|
<app- id="8" name="tasks"></app->
|
||||||
|
<app- id="9" name="chat"></app->
|
||||||
|
<app- id="10" name="announcements"></app->
|
||||||
|
<permission- id="1" key="events.add" app-id="3" description="Can add events"></permission->
|
||||||
|
<permission- id="2" key="events.delete" app-id="3" description="Can delete events"></permission->
|
||||||
|
<permission- id="3" key="events.edit" app-id="3" description="Can edit events"></permission->
|
||||||
|
<permission- id="4" key="events.get" app-id="3" description="Can view events"></permission->
|
||||||
|
<permission- id="5" key="jobs.add" app-id="7" description="Can add jobs"></permission->
|
||||||
|
<permission- id="6" key="jobs.delete" app-id="7" description="Can delete jobs"></permission->
|
||||||
|
<permission- id="7" key="jobs.edit" app-id="7" description="Can edit jobs"></permission->
|
||||||
|
<permission- id="8" key="jobs.get" app-id="7" description="Can view jobs"></permission->
|
||||||
|
<permission- id="9" key="announcements.add" app-id="10" description="Can add announcements"></permission->
|
||||||
|
<permission- id="10" key="announcements.delete" app-id="10" description="Can delete announcements"></permission->
|
||||||
|
<permission- id="11" key="announcements.edit" app-id="10" description="Can edit announcements"></permission->
|
||||||
|
<permission- id="12" key="announcements.get" app-id="10" description="Can view announcements"></permission->
|
||||||
|
<permission- id="13" key="role_apps.edit" app-id="1" description="Can edit role apps"></permission->
|
||||||
|
<permission- id="14" key="roles.create" app-id="1" description="Can create roles"></permission->
|
||||||
|
<permission- id="15" key="roles.delete" app-id="1" description="Can delete roles"></permission->
|
||||||
|
<permission- id="16" key="role_notifications.edit" app-id="1" description="Can edit role notifications"></permission->
|
||||||
|
<permission- id="17" key="chats.create" app-id="9" description="Can create chats"></permission->
|
||||||
|
<permission- id="18" key="chats.edit" app-id="9" description="Can edit chats"></permission->
|
||||||
|
<permission- id="19" key="chats.delete" app-id="9" description="Can delete chats"></permission->
|
||||||
|
<permission- id="20" key="chats_message.send" app-id="9" description="Can send messages"></permission->
|
||||||
|
<permission- id="21" key="chats_message.edit" app-id="9" description="Can edit messages"></permission->
|
||||||
|
<permission- id="22" key="chats_message.delete" app-id="9" description="Can delete messages"></permission->
|
||||||
|
<member-app- id="1" member-id="1" app-id="1"></member-app->
|
||||||
|
<member-app- id="2" member-id="4" app-id="1"></member-app->
|
||||||
|
<member-app- id="3" member-id="6" app-id="1"></member-app->
|
||||||
|
<member-app- id="4" member-id="1" app-id="2"></member-app->
|
||||||
|
<member-app- id="5" member-id="4" app-id="2"></member-app->
|
||||||
|
<member-app- id="6" member-id="6" app-id="2"></member-app->
|
||||||
|
<member-app- id="7" member-id="1" app-id="3"></member-app->
|
||||||
|
<member-app- id="8" member-id="4" app-id="3"></member-app->
|
||||||
|
<member-app- id="9" member-id="6" app-id="3"></member-app->
|
||||||
|
<member-app- id="10" member-id="1" app-id="4"></member-app->
|
||||||
|
<member-app- id="11" member-id="4" app-id="4"></member-app->
|
||||||
|
<member-app- id="12" member-id="6" app-id="4"></member-app->
|
||||||
|
<member-app- id="13" member-id="1" app-id="5"></member-app->
|
||||||
|
<member-app- id="14" member-id="4" app-id="5"></member-app->
|
||||||
|
<member-app- id="15" member-id="6" app-id="5"></member-app->
|
||||||
|
<member-app- id="16" member-id="1" app-id="6"></member-app->
|
||||||
|
<member-app- id="17" member-id="4" app-id="6"></member-app->
|
||||||
|
<member-app- id="18" member-id="6" app-id="6"></member-app->
|
||||||
|
<member-app- id="19" member-id="1" app-id="7"></member-app->
|
||||||
|
<member-app- id="20" member-id="4" app-id="7"></member-app->
|
||||||
|
<member-app- id="21" member-id="6" app-id="7"></member-app->
|
||||||
|
<member-app- id="22" member-id="1" app-id="8"></member-app->
|
||||||
|
<member-app- id="23" member-id="4" app-id="8"></member-app->
|
||||||
|
<member-app- id="24" member-id="6" app-id="8"></member-app->
|
||||||
|
<member-app- id="25" member-id="1" app-id="9"></member-app->
|
||||||
|
<member-app- id="26" member-id="4" app-id="10"></member-app->
|
||||||
|
<member-app- id="27" member-id="6" app-id="9"></member-app->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- network: cs -->
|
||||||
|
<!-- 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->
|
||||||
|
<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->
|
||||||
|
<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"></network-plan->
|
||||||
|
<join-code- id="1" code="cs" network-id="1"></join-code->
|
||||||
|
<member-network- id="1" member-id="1" network-id="1" created="2025-11-24 00:54:36.0784"></member-network->
|
||||||
|
<member-network- id="2" member-id="2" network-id="1" created="2026-01-13 13:14:28.0178"></member-network->
|
||||||
|
<member-network- id="3" member-id="3" network-id="1" created="2026-01-13 13:28:35.0701"></member-network->
|
||||||
|
<role- id="1" network-id="1" name="admin" is-default="false"></role->
|
||||||
|
<role- id="2" network-id="1" name="member" is-default="true"></role->
|
||||||
|
<role-app- id="1" role-id="1" app-id="1" network-id="1"></role-app->
|
||||||
|
<role-app- id="2" role-id="1" app-id="2" network-id="1"></role-app->
|
||||||
|
<role-app- id="3" role-id="1" app-id="3" network-id="1"></role-app->
|
||||||
|
<role-app- id="4" role-id="1" app-id="4" network-id="1"></role-app->
|
||||||
|
<role-app- id="5" role-id="1" app-id="5" network-id="1"></role-app->
|
||||||
|
<role-app- id="6" role-id="1" app-id="6" network-id="1"></role-app->
|
||||||
|
<role-app- id="7" role-id="1" app-id="7" network-id="1"></role-app->
|
||||||
|
<role-app- id="8" role-id="1" app-id="8" network-id="1"></role-app->
|
||||||
|
<role-app- id="9" role-id="1" app-id="9" network-id="1"></role-app->
|
||||||
|
<role-app- id="10" role-id="1" app-id="10" network-id="1"></role-app->
|
||||||
|
<role-permission- id="1" role-id="1" permission-key="events.get" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="2" role-id="1" permission-key="jobs.get" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="3" role-id="1" permission-key="announcements.get" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="4" role-id="1" permission-key="events.add" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="5" role-id="1" permission-key="events.delete" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="6" role-id="1" permission-key="events.edit" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="7" role-id="1" permission-key="jobs.add" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="8" role-id="1" permission-key="jobs.delete" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="9" role-id="1" permission-key="jobs.edit" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="10" role-id="1" permission-key="announcements.add" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="11" role-id="1" permission-key="announcements.delete" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="12" role-id="1" permission-key="announcements.edit" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="13" role-id="1" permission-key="role_apps.edit" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="14" role-id="1" permission-key="roles.create" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="15" role-id="1" permission-key="roles.delete" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="16" role-id="1" permission-key="chats.create" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="17" role-id="1" permission-key="chats.edit" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="18" role-id="1" permission-key="chats.delete" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="19" role-id="1" permission-key="chats_message.send" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="20" role-id="1" permission-key="chats_message.edit" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="21" role-id="1" permission-key="chats_message.delete" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="22" role-id="1" permission-key="role_notifications.edit" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="23" role-id="2" permission-key="events.get" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="24" role-id="2" permission-key="jobs.get" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="25" role-id="2" permission-key="announcements.get" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="26" role-id="2" permission-key="events.add" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="27" role-id="2" permission-key="chats.create" network-id="1"></role-permission->
|
||||||
|
<role-permission- id="28" role-id="2" permission-key="chats_message.send" network-id="1"></role-permission->
|
||||||
|
<member-role- id="1" member-id="1" role-id="1" granted-by="1" network-id="1"></member-role->
|
||||||
|
<member-role- id="2" member-id="2" role-id="2" granted-by="1" network-id="1"></member-role->
|
||||||
|
<member-role- id="3" member-id="3" role-id="2" granted-by="1" network-id="1"></member-role->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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"></join-form->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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->
|
||||||
|
<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"></contact-form->
|
||||||
|
|
||||||
|
</forms->
|
||||||
BIN
priv/networks/cs/website/_/IMFell/IMFell.ttf
Normal file
93
priv/networks/cs/website/_/IMFell/OFL.txt
Normal file
@@ -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.
|
||||||
BIN
priv/networks/cs/website/_/atlas.webp
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
priv/networks/cs/website/_/atlasmobile.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
29
priv/networks/cs/website/_/logo.svg
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<svg width="534" height="534" viewBox="0 0 534 534" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="9" height="534" fill="black"/>
|
||||||
|
<rect x="21" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="42" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="63" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="84" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="105" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="126" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="147" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="168" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="189" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="210" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="231" width="9" height="534" fill="black"/>
|
||||||
|
<circle cx="266.5" cy="271.5" r="127.5" fill="#FF0000"/>
|
||||||
|
<rect x="273" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="294" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="315" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="336" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="357" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="378" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="399" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="420" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="441" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="462" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="483" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="504" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="525" width="9" height="534" fill="black"/>
|
||||||
|
<rect x="252" width="9" height="534" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
priv/networks/cs/website/_/moon.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
priv/networks/cs/website/_/stars.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
priv/networks/cs/website/_/text.png
Normal file
|
After Width: | Height: | Size: 767 KiB |
460
priv/networks/cs/website/index.html
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Captured Sun</title>
|
||||||
|
<link rel="icon" href="/_/logo.svg">
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IMFell';
|
||||||
|
src: url('/_/IMFell/IMFell.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #000;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||||
|
|
||||||
|
img {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate-stars {
|
||||||
|
0% { transform: rotate(0deg) translateX(0); }
|
||||||
|
25% { transform: rotate(90deg) translateX(50px); }
|
||||||
|
50% { transform: rotate(180deg) translateX(0); }
|
||||||
|
75% { transform: rotate(270deg) translateX(-50px); }
|
||||||
|
100% { transform: rotate(360deg) translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
position: absolute;
|
||||||
|
animation: rotate-stars 950s linear infinite;
|
||||||
|
transform-origin: center center;
|
||||||
|
top: -550px;
|
||||||
|
width: 1600px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moon {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100vw;
|
||||||
|
left: 0px;
|
||||||
|
bottom: 0px; /* seam overlap — tweak to taste */
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -100px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 85vw;
|
||||||
|
height: auto;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.atlas img { display: block; width: 100%; height: auto; }
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 440px; left: 0; right: 0;
|
||||||
|
display: flex; justify-content: center; gap: 18px;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'IMFell';
|
||||||
|
backdrop-filter: blur(14px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(160%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0.5px 0 rgba(255, 255, 255, 0.35),
|
||||||
|
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.btn-red {
|
||||||
|
background: rgba(220, 38, 38, 0.30);
|
||||||
|
color: #d06868;
|
||||||
|
border: 0.5px solid rgba(254, 202, 202, 0.05);
|
||||||
|
}
|
||||||
|
.btn-orange {
|
||||||
|
background: rgba(234, 88, 12, 0.30);
|
||||||
|
color: #d79d5a;
|
||||||
|
border: 0.5px solid rgba(254, 215, 170, 0.05);
|
||||||
|
}
|
||||||
|
.btn-yellow {
|
||||||
|
background: rgba(234, 179, 8, 0.30);
|
||||||
|
color: #d0c04b;
|
||||||
|
border: 0.5px solid rgba(254, 240, 138, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(14px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(160%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0.5px 0 rgba(255, 255, 255, 0.35),
|
||||||
|
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
transition:
|
||||||
|
top 460ms cubic-bezier(0.32, 0.72, 0.20, 1),
|
||||||
|
left 460ms cubic-bezier(0.32, 0.72, 0.20, 1),
|
||||||
|
width 460ms cubic-bezier(0.32, 0.72, 0.20, 1),
|
||||||
|
height 460ms cubic-bezier(0.32, 0.72, 0.20, 1),
|
||||||
|
border-radius 460ms cubic-bezier(0.32, 0.72, 0.20, 1);
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: 'IMFell';
|
||||||
|
}
|
||||||
|
.menu.is-active { visibility: visible; pointer-events: auto; }
|
||||||
|
|
||||||
|
.menu-red { background: rgba(220, 38, 38, 0.30); color: #fab2b2; border: 0.5px solid rgba(254, 202, 202, 0.05); }
|
||||||
|
.menu-orange { background: rgba(234, 88, 12, 0.30); color: #d79d5a; border: 0.5px solid rgba(254, 215, 170, 0.05); }
|
||||||
|
.menu-yellow { background: rgba(234, 179, 8, 0.30); color: #d0c04b; border: 0.5px solid rgba(254, 240, 138, 0.05); }
|
||||||
|
|
||||||
|
.menu-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px; right: 18px;
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.10);
|
||||||
|
background: rgba(0, 0, 0, 0.20);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 180ms ease, background 150ms ease;
|
||||||
|
z-index: 2;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.menu-close:hover { background: rgba(0, 0, 0, 0.35); }
|
||||||
|
.menu.is-open .menu-close { opacity: 1; }
|
||||||
|
|
||||||
|
.menu-inner {
|
||||||
|
padding: 72px 56px 56px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: opacity 220ms ease, transform 220ms ease;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.menu.is-open .menu-inner {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition-delay: 280ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu h2 {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.menu p {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.link-list a {
|
||||||
|
display: block;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 150ms ease, transform 150ms ease;
|
||||||
|
}
|
||||||
|
.link-list a:hover { background: rgba(0, 0, 0, 0.30); transform: translateY(-1px); }
|
||||||
|
.link-list .label { display: block; font-size: 17px; }
|
||||||
|
.link-list .desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.contact-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.contact-form input,
|
||||||
|
.contact-form textarea {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #f0e6c8;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.50);
|
||||||
|
border: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 150ms ease, background 150ms ease;
|
||||||
|
}
|
||||||
|
.contact-form input::placeholder,
|
||||||
|
.contact-form textarea::placeholder { color: rgba(240, 230, 200, 0.35); }
|
||||||
|
.contact-form input:focus,
|
||||||
|
.contact-form textarea:focus {
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
.contact-form textarea { resize: vertical; min-height: 140px; }
|
||||||
|
.contact-form .submit {
|
||||||
|
justify-self: end;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 12px 26px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0.5px solid rgba(254, 240, 138, 0.10);
|
||||||
|
background: rgba(234, 179, 8, 0.40);
|
||||||
|
color: inherit;
|
||||||
|
font-family: 'IMFell';
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
.contact-form .submit:hover { background: rgba(234, 179, 8, 0.55); transform: translateY(-1px); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.space {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
top: 0px;
|
||||||
|
display:none;
|
||||||
|
width: 1200px;
|
||||||
|
}
|
||||||
|
.moon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.stars {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.atlas {
|
||||||
|
top: -200px;
|
||||||
|
width: 1000px;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
bottom: 1vh
|
||||||
|
}
|
||||||
|
.menu-red {
|
||||||
|
color: rgb(255, 255, 255) !important
|
||||||
|
}
|
||||||
|
.menu-inner { padding: 64px 28px 28px; }
|
||||||
|
.menu h2 { font-size: 28px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="cursor: default">
|
||||||
|
|
||||||
|
<img class="stars" src="/_/stars.png" alt="">
|
||||||
|
<img class="moon" src="/_/moon.png" alt="">
|
||||||
|
|
||||||
|
<picture class="atlas">
|
||||||
|
<source media="(max-width: 768px)" srcset="/_/atlasmobile.png">
|
||||||
|
<img src="/_/atlas.webp" alt="">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<button class="btn btn-red">About</button>
|
||||||
|
<!-- <button class="btn btn-orange">Our Work</button> -->
|
||||||
|
<button class="btn btn-orange">Contact Us</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="menu menu-red" id="menu-about">
|
||||||
|
<button class="menu-close" aria-label="Close">X</button>
|
||||||
|
<div class="menu-inner">
|
||||||
|
<h2>About</h2>
|
||||||
|
<p><i>All energy comes from the sun. To think is to capture, freeze, and reflect it.</i></p>
|
||||||
|
<p>Captured Sun was founded in 2022, to answer a simple question: what would the operating system look like if it was rearranged for the modern day?</p>
|
||||||
|
<p>The answer we have found is <a style="color: inherit;" href="https://frm.so" target="_blank">Forum</a>: the Community Operating System.</p>
|
||||||
|
<p>In concert with Forum, we are happy to accept certain consulting jobs with people or organizations that need custom solutions. If that's you, feel free to reach out.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu menu-orange" id="menu-work">
|
||||||
|
<button class="menu-close" aria-label="Close">X</button>
|
||||||
|
<div class="menu-inner">
|
||||||
|
<h2>Our Work</h2>
|
||||||
|
<ul class="link-list">
|
||||||
|
<li><a href="https://apple.com" target="_blank" rel="noopener"><span class="label">Apple</span><span class="desc">Product launch microsite</span></a></li>
|
||||||
|
<li><a href="https://vercel.com" target="_blank" rel="noopener"><span class="label">Vercel</span><span class="desc">Edge runtime documentation</span></a></li>
|
||||||
|
<li><a href="https://linear.app" target="_blank" rel="noopener"><span class="label">Linear</span><span class="desc">Marketing site redesign</span></a></li>
|
||||||
|
<li><a href="https://figma.com" target="_blank" rel="noopener"><span class="label">Figma</span><span class="desc">Plugin gallery</span></a></li>
|
||||||
|
<li><a href="https://stripe.com" target="_blank" rel="noopener"><span class="label">Stripe</span><span class="desc">Developer onboarding flow</span></a></li>
|
||||||
|
<li><a href="https://railway.com" target="_blank" rel="noopener"><span class="label">Railway</span><span class="desc">Pricing page experiments</span></a></li>
|
||||||
|
<li><a href="https://anthropic.com" target="_blank" rel="noopener"><span class="label">Anthropic</span><span class="desc">Research publication template</span></a></li>
|
||||||
|
<li><a href="https://notion.so" target="_blank" rel="noopener"><span class="label">Notion</span><span class="desc">Template marketplace</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu menu-orange" id="menu-contact">
|
||||||
|
<button class="menu-close" aria-label="Close"><span>X</span></button>
|
||||||
|
<div class="menu-inner">
|
||||||
|
<h2>Contact Us</h2>
|
||||||
|
<form class="contact-form" novalidate onsubmit="return handleContactSubmit(event, this)">
|
||||||
|
<label>Name <input type="text" name="name" placeholder="Your name" required></label>
|
||||||
|
<label>Email <input type="email" name="email" placeholder="you@example.com"></label>
|
||||||
|
<label>Phone <input type="tel" name="phone" placeholder="+1 555 123 4567"></label>
|
||||||
|
<label>Message <textarea name="message" placeholder="Tell us about your project..." required></textarea></label>
|
||||||
|
<button class="submit" type="submit">Send Message</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function handleContactSubmit(event, form) {
|
||||||
|
event.preventDefault();
|
||||||
|
var email = form.email.value.trim();
|
||||||
|
var phone = form.phone.value.trim();
|
||||||
|
if (!email && !phone) {
|
||||||
|
form.email.setCustomValidity('Provide an email or a phone number.');
|
||||||
|
form.email.reportValidity();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
form.email.setCustomValidity('');
|
||||||
|
if (!form.checkValidity()) { form.reportValidity(); return false; }
|
||||||
|
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var pairs = [
|
||||||
|
{ btn: document.querySelector('.btn-red'), menu: document.getElementById('menu-about') },
|
||||||
|
{ btn: document.querySelector('.btn-orange'), menu: document.getElementById('menu-contact') }
|
||||||
|
];
|
||||||
|
|
||||||
|
var active = null;
|
||||||
|
var FINAL_RADIUS = '18px';
|
||||||
|
|
||||||
|
function applyRect(menu, rect) {
|
||||||
|
menu.style.top = rect.top + 'px';
|
||||||
|
menu.style.left = rect.left + 'px';
|
||||||
|
menu.style.width = rect.width + 'px';
|
||||||
|
menu.style.height = rect.height + 'px';
|
||||||
|
menu.style.borderRadius = '11px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFinal(menu) {
|
||||||
|
menu.style.top = '10vh';
|
||||||
|
menu.style.left = '10vw';
|
||||||
|
menu.style.width = '80vw';
|
||||||
|
menu.style.height = '80vh';
|
||||||
|
menu.style.borderRadius = FINAL_RADIUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu(pair) {
|
||||||
|
if (active) closeMenu();
|
||||||
|
var btn = pair.btn, menu = pair.menu;
|
||||||
|
if (menu._closeTimer) { clearTimeout(menu._closeTimer); menu._closeTimer = null; }
|
||||||
|
|
||||||
|
var rect = btn.getBoundingClientRect();
|
||||||
|
menu.style.transition = 'none';
|
||||||
|
applyRect(menu, rect);
|
||||||
|
menu.classList.add('is-active');
|
||||||
|
void menu.offsetHeight;
|
||||||
|
menu.style.transition = '';
|
||||||
|
|
||||||
|
btn.style.visibility = 'hidden';
|
||||||
|
applyFinal(menu);
|
||||||
|
menu.classList.add('is-open');
|
||||||
|
active = pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
if (!active) return;
|
||||||
|
var btn = active.btn, menu = active.menu;
|
||||||
|
var rect = btn.getBoundingClientRect();
|
||||||
|
applyRect(menu, rect);
|
||||||
|
menu.classList.remove('is-open');
|
||||||
|
|
||||||
|
if (menu._closeTimer) clearTimeout(menu._closeTimer);
|
||||||
|
menu._closeTimer = setTimeout(function () {
|
||||||
|
menu.classList.remove('is-active');
|
||||||
|
btn.style.visibility = '';
|
||||||
|
menu._closeTimer = null;
|
||||||
|
}, 460);
|
||||||
|
|
||||||
|
active = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs.forEach(function (pair) {
|
||||||
|
pair.btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
openMenu(pair);
|
||||||
|
});
|
||||||
|
pair.menu.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||||
|
pair.menu.querySelector('.menu-close').addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function () {
|
||||||
|
if (active) closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && active) closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
if (!active) return;
|
||||||
|
applyFinal(active.menu);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
priv/schema.sql
Normal file
@@ -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()
|
||||||
|
);
|
||||||
496
priv/ui/admin.html
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>admin</title>
|
||||||
|
<link rel="icon" href="/admin/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: 1px solid rgba(254, 232, 200, 0.2); text-align: left; vertical-align: top; }
|
||||||
|
td { color: #FEBA7D; }
|
||||||
|
th { background: #6C0000; color: #FEBA7D; position: sticky; top: 0; }
|
||||||
|
tr:hover { background: #9D1A12; }
|
||||||
|
.num { text-align: right; }
|
||||||
|
.name, .mod { color: #ffe1c6; }
|
||||||
|
#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; }
|
||||||
|
</style>
|
||||||
|
<script src="https://frm.so/_/code/quill.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="tabs">
|
||||||
|
<button id="tab-processes" class="active">processes</button>
|
||||||
|
<button id="tab-modules">modules</button>
|
||||||
|
<button id="tab-logs">logs</button>
|
||||||
|
<button id="tab-vm-memory">vm memory</button>
|
||||||
|
<span id="status">connecting…</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="panel-processes">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="p-mine" class="active">mine</button>
|
||||||
|
<button id="p-all">all</button>
|
||||||
|
<span>|</span>
|
||||||
|
<button id="p-expand">expand all</button>
|
||||||
|
<button id="p-collapse">collapse all</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>pid</th><th>name</th><th>initial call</th>
|
||||||
|
<th class="num">memory (KB)</th><th class="num">tree memory (KB)</th>
|
||||||
|
<th class="num">msgs</th><th>status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="p-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-modules" hidden>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="m-mine" class="active">mine</button>
|
||||||
|
<button id="m-all">all</button>
|
||||||
|
<input id="m-filter" placeholder="filter…" autocomplete="off">
|
||||||
|
<button id="m-refresh">refresh</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>module</th><th>app</th><th>source</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="m-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-logs" hidden>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="l-refresh">refresh</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>time (Chicago)</th><th>source ip</th><th>host</th><th>method</th><th>path</th>
|
||||||
|
<th class="num">status</th><th class="num">duration ms</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="l-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-vm-memory" hidden>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="v-refresh">refresh</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>category</th><th class="num">bytes</th>
|
||||||
|
<th class="num">KB</th><th class="num">MB</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="v-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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 chicagoTimeParts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "America/Chicago",
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
|
||||||
|
function pollLogs() { send({type: "list_logs"}); }
|
||||||
|
|
||||||
|
function formatLogTime(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
chicagoTimeParts.formatToParts(date).map(part => [part.type, part.value])
|
||||||
|
);
|
||||||
|
return `${parts.month}.${parts.day} ${parts.hour}:${parts.minute}:${parts.second}${parts.dayPeriod.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
490
priv/ui/admin.pug
Normal file
@@ -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"; });
|
||||||
93
priv/ui/desktop.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>forum</title>
|
||||||
|
<style>
|
||||||
|
body { font: 14px ui-monospace, monospace; margin: 2rem; background: #f7f7f4; color: #171717; }
|
||||||
|
#status { color: #888; font-size: 12px; }
|
||||||
|
#doc { display: grid; gap: 24px; margin-top: 1rem; }
|
||||||
|
.form-table h2 { margin: 0 0 8px; font-size: 16px; }
|
||||||
|
.table-wrap { overflow-x: auto; border: 1px solid #d7d7cf; background: #fff; }
|
||||||
|
table { width: 100%; border-collapse: collapse; min-width: 920px; }
|
||||||
|
th, td { border-bottom: 1px solid #e6e6df; padding: 8px 10px; text-align: left; vertical-align: top; }
|
||||||
|
th { position: sticky; top: 0; background: #efefea; color: #555; font-size: 12px; }
|
||||||
|
tbody tr:hover { background: #fafaf7; }
|
||||||
|
td { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||||
|
code { font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="status">connecting…</div>
|
||||||
|
<main id="doc"><!-- FORMS_HTML --></main>
|
||||||
|
<script>
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
const doc = document.getElementById("doc");
|
||||||
|
|
||||||
|
const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
|
||||||
|
let ws = null;
|
||||||
|
let heartbeat = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let reconnectDelay = 500;
|
||||||
|
|
||||||
|
function send(obj) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
heartbeat = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer) return;
|
||||||
|
|
||||||
|
status.textContent = "disconnected; reconnecting…";
|
||||||
|
stopHeartbeat();
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, reconnectDelay);
|
||||||
|
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = "connecting…";
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
reconnectDelay = 500;
|
||||||
|
status.textContent = "connected";
|
||||||
|
send({type: "get_doc"});
|
||||||
|
stopHeartbeat();
|
||||||
|
heartbeat = setInterval(() => send({type: "ping"}), 25000);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("message", (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === "doc") {
|
||||||
|
doc.innerHTML = msg.html;
|
||||||
|
status.textContent = "rendered backend document at " + new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("close", scheduleReconnect);
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
status.textContent = "connection error; reconnecting…";
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", stopHeartbeat);
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
priv/ui/graphyellow.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="30" height="28" viewBox="0 0 30 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="14.5" cy="14.5" r="6" fill="#E1FF00" stroke="#FEBA7D"/>
|
||||||
|
<circle cx="26.5" cy="3.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
|
||||||
|
<circle cx="8.5" cy="3.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
|
||||||
|
<circle cx="3.5" cy="19.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
|
||||||
|
<circle cx="26.5" cy="24.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
|
||||||
|
<rect x="19.3531" y="10.04" width="6.20598" height="0.5" transform="rotate(-42 19.3531 10.04)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
|
||||||
|
<rect x="11.5018" y="8.76443" width="2.48193" height="0.5" transform="rotate(-118 11.5018 8.76443)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
|
||||||
|
<rect x="8.74289" y="17.7719" width="2.12617" height="0.5" transform="rotate(149 8.74289 17.7719)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
|
||||||
|
<rect x="19.692" y="18.3533" width="5.65271" height="0.5" transform="rotate(43 19.692 18.3533)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
18
priv/ui/pug-demo/README.md
Normal file
@@ -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`.
|
||||||
8
priv/ui/pug-demo/_mixins.pug
Normal file
@@ -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)
|
||||||
12
priv/ui/pug-demo/_summary.pug
Normal file
@@ -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}
|
||||||
|
| .
|
||||||
149
priv/ui/pug-demo/index.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pug Features Demo</title>
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>Pug features for Ada Lovelace</h1>
|
||||||
|
<p class="muted">This page is rendered from <code>index.pug</code> and extends <code>layout.pug</code>.</p>
|
||||||
|
</header>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Interpolation</h2>
|
||||||
|
<p>Hello, Ada Lovelace. Your current plan is PRO.</p>
|
||||||
|
<p>The literal syntax is <code>#{name}</code>, which inserts escaped text into a line.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Conditionals</h2>
|
||||||
|
<p>Welcome back, Ada Lovelace.</p>
|
||||||
|
<p>You are using the pro plan, so all examples are visible.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Loops and Mixins</h2>
|
||||||
|
<ul class="feature-grid">
|
||||||
|
<li class="feature-card">
|
||||||
|
<h3>Interpolation</h3>
|
||||||
|
<p class="muted">Drop values into text with #{name} and escaped output.</p><span class="badge badge--ready">ready</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-card">
|
||||||
|
<h3>Conditionals</h3>
|
||||||
|
<p class="muted">Render different branches with if, else if, else, and case.</p><span class="badge badge--ready">ready</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-card">
|
||||||
|
<h3>Loops</h3>
|
||||||
|
<p class="muted">Repeat markup with each item in collection syntax.</p><span class="badge badge--ready">ready</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-card">
|
||||||
|
<h3>Includes and extends</h3>
|
||||||
|
<p class="muted">Compose pages from layouts and smaller partial files.</p><span class="badge badge--practice">practice</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-card">
|
||||||
|
<h3>Mixins</h3>
|
||||||
|
<p class="muted">Create reusable snippets that accept arguments.</p><span class="badge badge--ready">ready</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Included Summary</h2>
|
||||||
|
<p>This section comes from <code>_summary.pug</code>. It can still read variables declared in <code>index.pug</code>, including <strong>Ada Lovelace</strong> and the feature count: <strong>5</strong>.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
priv/ui/pug-demo/index.pug
Normal file
@@ -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
|
||||||
102
priv/ui/pug-demo/layout.pug
Normal file
@@ -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
|
||||||
360
prod.log
Normal file
@@ -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
|
||||||
8
restart.sh
Executable file
@@ -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
|
||||||
1
start.sh
Executable file
@@ -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
|
||||||
1
terminal.sh
Executable file
@@ -0,0 +1 @@
|
|||||||
|
iex --name console@localhost --cookie lookatthepreviousweekend --remsh a@10.0.0.1
|
||||||