<%= get_flash(@conn, :info) %>
21 |<%= get_flash(@conn, :error) %>
22 | <%= @inner_content %> 23 |├── .formatter.exs ├── .gitignore ├── .iex.exs ├── .tool-versions ├── README.md ├── assets ├── .babelrc ├── css │ ├── app.css │ └── phoenix.css ├── js │ ├── app.js │ ├── document.js │ └── socket.js ├── package-lock.json ├── package.json ├── static │ ├── favicon.ico │ ├── images │ │ └── phoenix.png │ └── robots.txt └── webpack.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── prod.secret.exs └── test.exs ├── lib ├── collab.ex ├── collab │ ├── application.ex │ ├── document.ex │ └── document_supervisor.ex ├── collab_web.ex └── collab_web │ ├── channels │ ├── doc_channel.ex │ └── user_socket.ex │ ├── controllers │ └── page_controller.ex │ ├── endpoint.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ ├── layout │ │ └── app.html.eex │ └── page │ │ ├── index.html.eex │ │ └── view.html.eex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ ├── layout_view.ex │ └── page_view.ex ├── mix.exs ├── mix.lock └── test ├── collab_web ├── channels │ └── doc_channel_test.exs ├── controllers │ └── page_controller_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs ├── support ├── channel_case.ex └── conn_case.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | collab-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | alias Delta.Op 2 | alias Collab.{ 3 | Document, 4 | Document.Supervisor, 5 | } 6 | 7 | 8 | # Create some initial docs 9 | Document.update("hello", [Op.insert("Hello World!")], 0) 10 | Document.update("goat", [Op.insert("go")], 0) 11 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 14.17.1 2 | erlang 23.1 3 | elixir 1.12.0 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collab 2 | 3 | Start server: 4 | 5 | ```sh 6 | npm i --prefix assets 7 | mix deps.get 8 | mix phx.server 9 | ``` 10 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | @import "./phoenix.css"; 3 | 4 | 5 | .phx-hero input { 6 | width: 150px; 7 | } 8 | 9 | 10 | #app-title { 11 | margin: 20px 0; 12 | } 13 | 14 | #editor { 15 | resize: none; 16 | height: calc(100vh - 250px); 17 | min-height: 200px; 18 | font-size: 22px; 19 | font-family: monospace; 20 | padding: 20px; 21 | } 22 | 23 | @media (max-width: 40.0rem) { 24 | .column:not(:last-child) { 25 | margin-bottom: 70px; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.3.0 https://milligram.github.io 6 | * Copyright (c) 2017 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{-ms-grid-row-align:center;align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | pre{padding: 1em;} 19 | 20 | .container{ 21 | margin: 0 auto; 22 | max-width: 80.0rem; 23 | padding: 0 2.0rem; 24 | position: relative; 25 | width: 100% 26 | } 27 | select { 28 | width: auto; 29 | } 30 | 31 | /* Phoenix promo and logo */ 32 | .phx-hero { 33 | border-bottom: 1px solid #e3e3e3; 34 | background: #eee; 35 | border-radius: 6px; 36 | padding: 3em 3em 1em; 37 | margin-bottom: 3rem; 38 | font-weight: 200; 39 | font-size: 120%; 40 | } 41 | .phx-hero input { 42 | background: #ffffff; 43 | } 44 | .phx-logo { 45 | min-width: 300px; 46 | margin: 1rem; 47 | display: block; 48 | } 49 | .phx-logo img { 50 | width: auto; 51 | display: block; 52 | } 53 | 54 | /* Headers */ 55 | header { 56 | width: 100%; 57 | background: #fdfdfd; 58 | border-bottom: 1px solid #eaeaea; 59 | margin-bottom: 2rem; 60 | } 61 | header section { 62 | align-items: center; 63 | display: flex; 64 | flex-direction: column; 65 | justify-content: space-between; 66 | } 67 | header section :first-child { 68 | order: 2; 69 | } 70 | header section :last-child { 71 | order: 1; 72 | } 73 | header nav ul, 74 | header nav li { 75 | margin: 0; 76 | padding: 0; 77 | display: block; 78 | text-align: right; 79 | white-space: nowrap; 80 | } 81 | header nav ul { 82 | margin: 1rem; 83 | margin-top: 0; 84 | } 85 | header nav a { 86 | display: block; 87 | } 88 | 89 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 90 | header section { 91 | flex-direction: row; 92 | } 93 | header nav ul { 94 | margin: 1rem; 95 | } 96 | .phx-logo { 97 | flex-basis: 527px; 98 | margin: 2rem 1rem; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import "../css/app.css" 5 | 6 | import "phoenix_html" 7 | import socket from "./socket" 8 | import Document from './document' 9 | 10 | 11 | const addListener = (selector, event, fun) => { 12 | const elem = document.querySelector(selector); 13 | if (elem) elem.addEventListener(event, fun); 14 | }; 15 | 16 | 17 | // New Document 18 | addListener('#new-doc', 'click', (e) => { 19 | const randomId = Math.random().toString(36).substring(2, 7); 20 | window.location = `/${randomId}`; 21 | }); 22 | 23 | // Open existing document 24 | addListener('#open-doc', 'submit', (e) => { 25 | e.preventDefault(); 26 | const id = new FormData(e.target).get('id'); 27 | window.location = `/${id}`; 28 | }); 29 | 30 | 31 | // Initialize editor 32 | window.doc = new Document('#editor', socket); 33 | -------------------------------------------------------------------------------- /assets/js/document.js: -------------------------------------------------------------------------------- 1 | import Delta from 'quill-delta'; 2 | 3 | export default class Document { 4 | editor = null; // DOM element reference 5 | channel = null; // Connected socket channel 6 | 7 | version = 0; // Local version 8 | contents = null; // Local contents 9 | committing = null; // Local change being currently pushed 10 | queued = null; // Pending change yet to be pushed 11 | 12 | constructor(selector, socket) { 13 | this.editor = document.querySelector(selector); 14 | 15 | if (this.editor) { 16 | const id = this.editor.dataset.id; 17 | this.channel = socket.channel(`doc:${id}`, {}); 18 | 19 | // Join document channel and set up event listeners 20 | this.channel 21 | .join() 22 | .receive('ok', () => { 23 | this.channel.on('open', (resp) => this.onOpen(resp)); 24 | this.channel.on('update', (resp) => this.onRemoteUpdate(resp)); 25 | this.editor.addEventListener('input', (e) => this.onLocalUpdate(e.target)); 26 | }) 27 | .receive('error', (resp) => { 28 | console.log('Socket Error', resp) 29 | }); 30 | } 31 | } 32 | 33 | 34 | // Show initial contents on joining the document channel 35 | onOpen({ contents, version }) { 36 | this.logState('CURRENT STATE'); 37 | 38 | this.version = version; 39 | this.contents = new Delta(contents); 40 | this.updateEditor(); 41 | 42 | this.logState('UPDATED STATE'); 43 | } 44 | 45 | 46 | // Track and push local changes 47 | onLocalUpdate({ value }) { 48 | this.logState('CURRENT STATE'); 49 | 50 | const newDelta = new Delta().insert(value); 51 | const change = this.contents.diff(newDelta); 52 | 53 | this.contents = newDelta; 54 | this.pushLocalChange(change); 55 | this.logState('UPDATED STATE'); 56 | } 57 | 58 | pushLocalChange(change) { 59 | if (this.committing) { 60 | // Queue new changes if we're already in the middle of 61 | // pushing previous changes to server 62 | this.queued = this.queued || new Delta(); 63 | this.queued = this.queued.compose(change); 64 | } else { 65 | const version = this.version; 66 | this.version += 1; 67 | this.committing = change; 68 | 69 | // setTimeout(() => { 70 | this.channel 71 | .push('update', { change: change.ops, version }) 72 | .receive('ok', (resp) => { 73 | console.log('ACK RECEIVED FOR', version, change.ops) 74 | this.committing = null; 75 | 76 | // Push any queued changes after receiving ACK 77 | // from server 78 | if (this.queued) { 79 | this.pushLocalChange(this.queued); 80 | this.queued = null; 81 | } 82 | }); 83 | // }, 2000); 84 | } 85 | } 86 | 87 | 88 | // Listen for remote changes 89 | onRemoteUpdate({ change, version }) { 90 | this.logState('CURRENT STATE'); 91 | console.log('RECEIVED', { version, change }) 92 | 93 | let remoteDelta = new Delta(change); 94 | 95 | // Transform remote delta if we're in the middle 96 | // of pushing changes 97 | if (this.committing) { 98 | remoteDelta = this.committing.transform(remoteDelta, false); 99 | 100 | // If there are more queued changes the server hasn't seen 101 | // yet, transform both remote delta and queued changes on 102 | // each other to make the document consistent with server. 103 | if (this.queued) { 104 | const remotePending = this.queued.transform(remoteDelta, false); 105 | this.queued = remoteDelta.transform(this.queued, true); 106 | remoteDelta = remotePending; 107 | } 108 | } 109 | 110 | const newPosition = remoteDelta.transformPosition(this.editor.selectionStart); 111 | this.contents = this.contents.compose(remoteDelta); 112 | this.version += 1; 113 | this.updateEditor(newPosition); 114 | 115 | this.logState('UPDATED STATE'); 116 | } 117 | 118 | 119 | 120 | // Flatten delta to plain text and display value in editor 121 | updateEditor(position) { 122 | this.editor.value = 123 | this.contents.reduce((text, op) => { 124 | const val = (typeof op.insert === 'string') ? op.insert : ''; 125 | return text + val; 126 | }, ''); 127 | 128 | if (position) { 129 | this.editor.selectionStart = position; 130 | this.editor.selectionEnd = position; 131 | } 132 | } 133 | 134 | logState(msg) { 135 | console.log(msg, { 136 | version: this.version, 137 | contents: this.contents && this.contents.ops[0] && this.contents.ops[0].insert, 138 | }); 139 | } 140 | }; 141 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket, 5 | // and connect at the socket path in "lib/web/endpoint.ex". 6 | // 7 | // Pass the token on params as below. Or remove it 8 | // from the params if you are not using authentication. 9 | import {Socket} from "phoenix" 10 | 11 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 12 | 13 | // When you connect, you'll often need to authenticate the client. 14 | // For example, imagine you have an authentication plug, `MyAuth`, 15 | // which authenticates the session and assigns a `:current_user`. 16 | // If the current user exists you can assign the user's token in 17 | // the connection for use in the layout. 18 | // 19 | // In your "lib/web/router.ex": 20 | // 21 | // pipeline :browser do 22 | // ... 23 | // plug MyAuth 24 | // plug :put_user_token 25 | // end 26 | // 27 | // defp put_user_token(conn, _) do 28 | // if current_user = conn.assigns[:current_user] do 29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 30 | // assign(conn, :user_token, token) 31 | // else 32 | // conn 33 | // end 34 | // end 35 | // 36 | // Now you need to pass this token to JavaScript. You can do so 37 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 38 | // 39 | // 40 | // 41 | // You will need to verify the user token in the "connect/3" function 42 | // in "lib/web/channels/user_socket.ex": 43 | // 44 | // def connect(%{"token" => token}, socket, _connect_info) do 45 | // # max_age: 1209600 is equivalent to two weeks in seconds 46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 47 | // {:ok, user_id} -> 48 | // {:ok, assign(socket, :user, user_id)} 49 | // {:error, reason} -> 50 | // :error 51 | // end 52 | // end 53 | // 54 | // Finally, connect to the socket: 55 | socket.connect() 56 | 57 | // // Now that you are connected, you can join channels with a topic: 58 | // let channel = socket.channel("topic:subtopic", {}) 59 | // channel.join() 60 | // .receive("ok", resp => { console.log("Joined successfully", resp) }) 61 | // .receive("error", resp => { console.log("Unable to join", resp) }) 62 | 63 | export default socket 64 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "description": " ", 4 | "license": "MIT", 5 | "scripts": { 6 | "deploy": "webpack --mode production", 7 | "watch": "webpack --mode development --watch" 8 | }, 9 | "dependencies": { 10 | "phoenix": "file:../deps/phoenix", 11 | "phoenix_html": "file:../deps/phoenix_html", 12 | "quill-delta": "github:quilljs/delta" 13 | }, 14 | "devDependencies": { 15 | "@babel/core": "^7.0.0", 16 | "@babel/preset-env": "^7.0.0", 17 | "babel-loader": "^8.0.0", 18 | "copy-webpack-plugin": "^5.1.1", 19 | "css-loader": "^3.4.2", 20 | "hard-source-webpack-plugin": "^0.13.1", 21 | "mini-css-extract-plugin": "^0.9.0", 22 | "node-sass": "^4.13.1", 23 | "optimize-css-assets-webpack-plugin": "^5.0.1", 24 | "sass-loader": "^8.0.2", 25 | "terser-webpack-plugin": "^2.3.2", 26 | "webpack": "^4.41.5", 27 | "webpack-cli": "^3.3.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sheharyarn/collab/3c0585e9860d05a33c6558af55ac3ad751be8281/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sheharyarn/collab/3c0585e9860d05a33c6558af55ac3ad751be8281/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const TerserPlugin = require('terser-webpack-plugin'); 6 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 7 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 8 | 9 | module.exports = (env, options) => { 10 | const devMode = options.mode !== 'production'; 11 | 12 | return { 13 | optimization: { 14 | minimizer: [ 15 | new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }), 16 | new OptimizeCSSAssetsPlugin({}) 17 | ] 18 | }, 19 | entry: { 20 | 'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js']) 21 | }, 22 | output: { 23 | filename: '[name].js', 24 | path: path.resolve(__dirname, '../priv/static/js'), 25 | publicPath: '/js/' 26 | }, 27 | devtool: devMode ? 'eval-cheap-module-source-map' : undefined, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.js$/, 32 | exclude: /node_modules/, 33 | use: { 34 | loader: 'babel-loader' 35 | } 36 | }, 37 | { 38 | test: /\.[s]?css$/, 39 | use: [ 40 | MiniCssExtractPlugin.loader, 41 | 'css-loader', 42 | 'sass-loader', 43 | ], 44 | } 45 | ] 46 | }, 47 | plugins: [ 48 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 49 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) 50 | ] 51 | .concat(devMode ? [new HardSourceWebpackPlugin()] : []) 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | 10 | # Configures the endpoint 11 | config :collab, CollabWeb.Endpoint, 12 | url: [host: "localhost"], 13 | secret_key_base: "3vn9BU2hV7SnMVGDHgBxlU0syfNkSdX/SEyYcgFXsioVmk1yh2WeXlFH20a7X7nB", 14 | render_errors: [view: CollabWeb.ErrorView, accepts: ~w(html json), layout: false], 15 | pubsub_server: Collab.PubSub, 16 | live_view: [signing_salt: "fWpGML+8"] 17 | 18 | # Configures Elixir's Logger 19 | config :logger, :console, 20 | format: "$time $metadata[$level] $message\n", 21 | metadata: [:request_id] 22 | 23 | # Use Jason for JSON parsing in Phoenix 24 | config :phoenix, :json_library, Jason 25 | 26 | # Import environment specific config. This must remain at the bottom 27 | # of this file so it overrides the configuration defined above. 28 | import_config "#{Mix.env()}.exs" 29 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with webpack to recompile .js and .css sources. 9 | config :collab, CollabWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [ 15 | node: [ 16 | "node_modules/webpack/bin/webpack.js", 17 | "--mode", 18 | "development", 19 | "--watch-stdin", 20 | cd: Path.expand("../assets", __DIR__) 21 | ] 22 | ] 23 | 24 | # ## SSL Support 25 | # 26 | # In order to use HTTPS in development, a self-signed 27 | # certificate can be generated by running the following 28 | # Mix task: 29 | # 30 | # mix phx.gen.cert 31 | # 32 | # Note that this task requires Erlang/OTP 20 or later. 33 | # Run `mix help phx.gen.cert` for more information. 34 | # 35 | # The `http:` config above can be replaced with: 36 | # 37 | # https: [ 38 | # port: 4001, 39 | # cipher_suite: :strong, 40 | # keyfile: "priv/cert/selfsigned_key.pem", 41 | # certfile: "priv/cert/selfsigned.pem" 42 | # ], 43 | # 44 | # If desired, both `http:` and `https:` keys can be 45 | # configured to run both http and https servers on 46 | # different ports. 47 | 48 | # Watch static and templates for browser reloading. 49 | config :collab, CollabWeb.Endpoint, 50 | live_reload: [ 51 | patterns: [ 52 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 53 | ~r"lib/collab_web/(live|views)/.*(ex)$", 54 | ~r"lib/collab_web/templates/.*(eex)$" 55 | ] 56 | ] 57 | 58 | # Do not include metadata nor timestamps in development logs 59 | config :logger, :console, format: "[$level] $message\n" 60 | 61 | # Set a higher stacktrace during development. Avoid configuring such 62 | # in production as building large stacktraces may be expensive. 63 | config :phoenix, :stacktrace_depth, 20 64 | 65 | # Initialize plugs at runtime for faster development compilation 66 | config :phoenix, :plug_init_mode, :runtime 67 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :collab, CollabWeb.Endpoint, 13 | url: [host: "example.com", port: 80], 14 | cache_static_manifest: "priv/static/cache_manifest.json" 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :collab, CollabWeb.Endpoint, 25 | # ... 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # port: 443, 29 | # cipher_suite: :strong, 30 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 31 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH"), 32 | # transport_options: [socket_opts: [:inet6]] 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :collab, CollabWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | 53 | # Finally import the config/prod.secret.exs which loads secrets 54 | # and configuration from environment variables. 55 | import_config "prod.secret.exs" 56 | -------------------------------------------------------------------------------- /config/prod.secret.exs: -------------------------------------------------------------------------------- 1 | # In this file, we load production configuration and secrets 2 | # from environment variables. You can also hardcode secrets, 3 | # although such is generally not recommended and you have to 4 | # remember to add this file to your .gitignore. 5 | use Mix.Config 6 | 7 | secret_key_base = 8 | System.get_env("SECRET_KEY_BASE") || 9 | raise """ 10 | environment variable SECRET_KEY_BASE is missing. 11 | You can generate one by calling: mix phx.gen.secret 12 | """ 13 | 14 | config :collab, CollabWeb.Endpoint, 15 | http: [ 16 | port: String.to_integer(System.get_env("PORT") || "4000"), 17 | transport_options: [socket_opts: [:inet6]] 18 | ], 19 | secret_key_base: secret_key_base 20 | 21 | # ## Using releases (Elixir v1.9+) 22 | # 23 | # If you are doing OTP releases, you need to instruct Phoenix 24 | # to start each relevant endpoint: 25 | # 26 | # config :collab, CollabWeb.Endpoint, server: true 27 | # 28 | # Then you can assemble a release by calling `mix release`. 29 | # See `mix help release` for more information. 30 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :collab, CollabWeb.Endpoint, 6 | http: [port: 4002], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | -------------------------------------------------------------------------------- /lib/collab.ex: -------------------------------------------------------------------------------- 1 | defmodule Collab do 2 | @moduledoc """ 3 | Collab keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/collab/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Collab.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | children = [ 6 | Collab.Document.Supervisor, 7 | {Phoenix.PubSub, name: Collab.PubSub}, 8 | CollabWeb.Endpoint 9 | ] 10 | 11 | opts = [strategy: :one_for_one, name: Collab.Supervisor] 12 | Supervisor.start_link(children, opts) 13 | end 14 | 15 | def config_change(changed, _new, removed) do 16 | CollabWeb.Endpoint.config_change(changed, removed) 17 | :ok 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/collab/document.ex: -------------------------------------------------------------------------------- 1 | defmodule Collab.Document do 2 | use GenServer 3 | alias __MODULE__.Supervisor 4 | 5 | @initial_state %{ 6 | version: 0, 7 | changes: [], 8 | contents: [], 9 | } 10 | 11 | 12 | # Public API 13 | # ---------- 14 | 15 | def start_link(id), do: GenServer.start_link(__MODULE__, :ok, name: name(id)) 16 | def stop(id), do: GenServer.stop(name(id)) 17 | 18 | def get_contents(id), do: call(id, :get_contents) 19 | def update(id, change, ver), do: call(id, {:update, change, ver}) 20 | 21 | def open(id) do 22 | case GenServer.whereis(name(id)) do 23 | nil -> DynamicSupervisor.start_child(Supervisor, {__MODULE__, id}) 24 | pid -> {:ok, pid} 25 | end 26 | end 27 | 28 | 29 | # Callbacks 30 | # --------- 31 | 32 | @impl true 33 | def init(:ok), do: {:ok, @initial_state} 34 | 35 | @impl true 36 | def handle_call(:get_contents, _from, state) do 37 | response = Map.take(state, [:version, :contents]) 38 | {:reply, response, state} 39 | end 40 | 41 | @impl true 42 | def handle_call({:update, client_change, client_version}, _from, state) do 43 | if client_version > state.version do 44 | # Error when client version is inconsistent with 45 | # server state 46 | {:reply, {:error, :server_behind}, state} 47 | else 48 | # Check how far behind client is 49 | changes_count = state.version - client_version 50 | 51 | # Transform client change if it was sent on an 52 | # older version of the document 53 | transformed_change = 54 | state.changes 55 | |> Enum.take(changes_count) 56 | |> Enum.reverse() 57 | |> Enum.reduce(client_change, &Delta.transform(&1, &2, true)) 58 | 59 | state = %{ 60 | version: state.version + 1, 61 | changes: [transformed_change | state.changes], 62 | contents: Delta.compose(state.contents, transformed_change), 63 | } 64 | 65 | response = %{ 66 | version: state.version, 67 | change: transformed_change, 68 | } 69 | 70 | {:reply, {:ok, response}, state} 71 | end 72 | end 73 | 74 | 75 | # Private Helpers 76 | # --------------- 77 | 78 | defp call(id, data) do 79 | with {:ok, pid} <- open(id), do: GenServer.call(pid, data) 80 | end 81 | 82 | defp name(id), do: {:global, {:doc, id}} 83 | end 84 | -------------------------------------------------------------------------------- /lib/collab/document_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Collab.Document.Supervisor do 2 | use DynamicSupervisor 3 | 4 | def start_link(_args) do 5 | DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__) 6 | end 7 | 8 | @impl true 9 | def init(:ok) do 10 | DynamicSupervisor.init(strategy: :one_for_one) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/collab_web.ex: -------------------------------------------------------------------------------- 1 | defmodule CollabWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use CollabWeb, :controller 9 | use CollabWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: CollabWeb 23 | 24 | import Plug.Conn 25 | alias CollabWeb.Router.Helpers, as: Routes 26 | end 27 | end 28 | 29 | def view do 30 | quote do 31 | use Phoenix.View, 32 | root: "lib/collab_web/templates", 33 | namespace: CollabWeb 34 | 35 | # Import convenience functions from controllers 36 | import Phoenix.Controller, 37 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 38 | 39 | # Include shared imports and aliases for views 40 | unquote(view_helpers()) 41 | end 42 | end 43 | 44 | def router do 45 | quote do 46 | use Phoenix.Router 47 | 48 | import Plug.Conn 49 | import Phoenix.Controller 50 | end 51 | end 52 | 53 | def channel do 54 | quote do 55 | use Phoenix.Channel 56 | end 57 | end 58 | 59 | defp view_helpers do 60 | quote do 61 | # Use all HTML functionality (forms, tags, etc) 62 | use Phoenix.HTML 63 | 64 | # Import basic rendering functionality (render, render_layout, etc) 65 | import Phoenix.View 66 | 67 | import CollabWeb.ErrorHelpers 68 | alias CollabWeb.Router.Helpers, as: Routes 69 | end 70 | end 71 | 72 | @doc """ 73 | When used, dispatch to the appropriate controller/view/etc. 74 | """ 75 | defmacro __using__(which) when is_atom(which) do 76 | apply(__MODULE__, which, []) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/collab_web/channels/doc_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule CollabWeb.DocChannel do 2 | use CollabWeb, :channel 3 | alias Collab.Document 4 | require Logger 5 | 6 | @impl true 7 | def join("doc:" <> id, _payload, socket) do 8 | {:ok, _pid} = Document.open(id) 9 | socket = assign(socket, :id, id) 10 | send(self(), :after_join) 11 | 12 | {:ok, socket} 13 | end 14 | 15 | @impl true 16 | def handle_info(:after_join, socket) do 17 | response = Document.get_contents(socket.assigns.id) 18 | push(socket, "open", response) 19 | 20 | {:noreply, socket} 21 | end 22 | 23 | @impl true 24 | def handle_in("update", %{"change" => change, "version" => version}, socket) do 25 | case Document.update(socket.assigns.id, change, version) do 26 | {:ok, response} -> 27 | # Process.sleep(1000) 28 | broadcast_from!(socket, "update", response) 29 | {:reply, :ok, socket} 30 | 31 | error -> 32 | Logger.error(inspect(error)) 33 | {:reply, {:error, inspect(error)}, socket} 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/collab_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule CollabWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | channel "doc:*", CollabWeb.DocChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | @impl true 19 | def connect(_params, socket, _connect_info) do 20 | {:ok, socket} 21 | end 22 | 23 | # Socket id's are topics that allow you to identify all sockets for a given user: 24 | # 25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 26 | # 27 | # Would allow you to broadcast a "disconnect" event and terminate 28 | # all active sockets and channels for a given user: 29 | # 30 | # CollabWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 31 | # 32 | # Returning `nil` makes this socket anonymous. 33 | @impl true 34 | def id(_socket), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /lib/collab_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CollabWeb.PageController do 2 | use CollabWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | 8 | def view(conn, %{"id" => id}) do 9 | render(conn, "view.html", id: id) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/collab_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule CollabWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :collab 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_collab_key", 10 | signing_salt: "S05Gh3Ha" 11 | ] 12 | 13 | socket "/socket", CollabWeb.UserSocket, 14 | websocket: true, 15 | longpoll: false 16 | 17 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phx.digest 22 | # when deploying your static files in production. 23 | plug Plug.Static, 24 | at: "/", 25 | from: :collab, 26 | gzip: false, 27 | only: ~w(css fonts images js favicon.ico robots.txt) 28 | 29 | # Code reloading can be explicitly enabled under the 30 | # :code_reloader configuration of your endpoint. 31 | if code_reloading? do 32 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 33 | plug Phoenix.LiveReloader 34 | plug Phoenix.CodeReloader 35 | end 36 | 37 | plug Phoenix.LiveDashboard.RequestLogger, 38 | param_key: "request_logger", 39 | cookie_key: "request_logger" 40 | 41 | plug Plug.RequestId 42 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 43 | 44 | plug Plug.Parsers, 45 | parsers: [:urlencoded, :multipart, :json], 46 | pass: ["*/*"], 47 | json_decoder: Phoenix.json_library() 48 | 49 | plug Plug.MethodOverride 50 | plug Plug.Head 51 | plug Plug.Session, @session_options 52 | plug CollabWeb.Router 53 | end 54 | -------------------------------------------------------------------------------- /lib/collab_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule CollabWeb.Router do 2 | use CollabWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | end 11 | 12 | pipeline :api do 13 | plug :accepts, ["json"] 14 | end 15 | 16 | scope "/", CollabWeb do 17 | pipe_through :browser 18 | 19 | get "/", PageController, :index 20 | get "/:id/", PageController, :view 21 | end 22 | 23 | # Other scopes may use custom stacks. 24 | # scope "/api", CollabWeb do 25 | # pipe_through :api 26 | # end 27 | 28 | # Enables LiveDashboard only for development 29 | # 30 | # If you want to use the LiveDashboard in production, you should put 31 | # it behind authentication and allow only admins to access it. 32 | # If your application does not have an admins-only section yet, 33 | # you can use Plug.BasicAuth to set up some basic authentication 34 | # as long as you are also using SSL (which you should anyway). 35 | if Mix.env() in [:dev, :test] do 36 | import Phoenix.LiveDashboard.Router 37 | 38 | scope "/" do 39 | pipe_through :browser 40 | live_dashboard "/dashboard", metrics: CollabWeb.Telemetry 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/collab_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule CollabWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # VM Metrics 34 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 35 | summary("vm.total_run_queue_lengths.total"), 36 | summary("vm.total_run_queue_lengths.cpu"), 37 | summary("vm.total_run_queue_lengths.io") 38 | ] 39 | end 40 | 41 | defp periodic_measurements do 42 | [ 43 | # A module, function and arguments to be invoked periodically. 44 | # This function must call :telemetry.execute/3 and a metric must be added above. 45 | # {CollabWeb, :count_users, []} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/collab_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 |<%= get_flash(@conn, :info) %>
21 |<%= get_flash(@conn, :error) %>
22 | <%= @inner_content %> 23 |<%= @id %>