mirror of
https://github.com/cesanta/mongoose.git
synced 2025-08-05 21:18:32 +08:00
Change device dashboard to use MQTT
This commit is contained in:
parent
f4fa1e097d
commit
219428c249
@ -1,5 +1,5 @@
|
||||
PROG ?= example
|
||||
SOURCES ?= ../../mongoose.c main.c web.c packed_fs.c
|
||||
SOURCES ?= ../../mongoose.c main.c net.c packed_fs.c
|
||||
CFLAGS ?= -I../.. -DMG_ENABLE_PACKED_FS=1 $(EXTRA)
|
||||
FILES_TO_EMBED ?= $(wildcard web_root/*)
|
||||
ROOT ?= $(realpath $(CURDIR)/../../..)
|
||||
|
@ -3,6 +3,10 @@
|
||||
|
||||
#include "mongoose.h"
|
||||
|
||||
#define MQTT_SERVER "mqtt://broker.hivemq.com:1883"
|
||||
#define MQTT_PUBLISH_TOPIC "mg/my_device"
|
||||
#define MQTT_SUBSCRIBE_TOPIC "mg/#"
|
||||
|
||||
// Authenticated user.
|
||||
// A user can be authenticated by:
|
||||
// - a name:pass pair
|
||||
@ -17,25 +21,18 @@ struct user {
|
||||
|
||||
// This is a configuration structure we're going to show on a dashboard
|
||||
static struct config {
|
||||
int value1;
|
||||
char *value2;
|
||||
} s_config = {123, NULL};
|
||||
char *url, *pub, *sub; // MQTT settings
|
||||
} s_config;
|
||||
|
||||
// Update config structure. Return true if changed, false otherwise
|
||||
static bool update_config(struct mg_http_message *hm, struct config *cfg) {
|
||||
bool changed = false;
|
||||
static struct mg_connection *s_mqtt = NULL; // MQTT connection
|
||||
|
||||
// Try to update a single configuration value
|
||||
static void update_config(struct mg_str *body, const char *name, char **value) {
|
||||
char buf[256];
|
||||
if (mg_http_get_var(&hm->body, "value1", buf, sizeof(buf)) > 0) {
|
||||
cfg->value1 = atoi(buf);
|
||||
changed = true;
|
||||
if (mg_http_get_var(body, name, buf, sizeof(buf)) > 0) {
|
||||
free(*value);
|
||||
*value = strdup(buf);
|
||||
}
|
||||
if (mg_http_get_var(&hm->body, "value2", buf, sizeof(buf)) > 0) {
|
||||
free(cfg->value2);
|
||||
cfg->value2 = malloc(strlen(buf) + 1);
|
||||
strcpy(cfg->value2, buf);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Parse HTTP requests, return authenticated user or NULL
|
||||
@ -75,7 +72,7 @@ static void send_notification(struct mg_mgr *mgr, const char *name,
|
||||
}
|
||||
|
||||
// Send simulated metrics data to the dashboard, for chart rendering
|
||||
static void timer_func(void *param) {
|
||||
static void timer_metrics_fn(void *param) {
|
||||
char buf[50];
|
||||
mg_snprintf(buf, sizeof(buf), "[ %lu, %d ]", (unsigned long) time(NULL),
|
||||
10 + (int) ((double) rand() * 10 / RAND_MAX));
|
||||
@ -83,11 +80,41 @@ static void timer_func(void *param) {
|
||||
send_notification(param, "metrics", buf);
|
||||
}
|
||||
|
||||
// MQTT event handler function
|
||||
static void mqtt_fn(struct mg_connection *c, int ev, void *evd, void *fnd) {
|
||||
if (ev == MG_EV_MQTT_OPEN) {
|
||||
struct mg_str topic = mg_str(s_config.pub);
|
||||
mg_mqtt_sub(s_mqtt, &topic, 1);
|
||||
} else if (ev == MG_EV_MQTT_MSG) {
|
||||
struct mg_mqtt_message *mm = evd;
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), "{\"topic\":\"%.*s\",\"data\":\"%.*s\"}",
|
||||
(int) mm->topic.len, mm->topic.ptr, (int) mm->data.len,
|
||||
mm->data.ptr);
|
||||
send_notification(param, "message", buf);
|
||||
} else if (ev == MG_EV_CLOSE) {
|
||||
s_mqtt = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep MQTT connection open - reconnect if closed
|
||||
static void timer_metrics_fn(void *param) {
|
||||
struct mg_mgr *mgr = (struct mg_mgr *) param;
|
||||
if (s_mqtt == NULL) {
|
||||
struct mg_mqtt_opts opts = {};
|
||||
s_mqtt = mg_mqtt_connect(mgr, s_config.rl, &opts, mqtt_fn, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP request handler function
|
||||
void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
|
||||
void *fn_data) {
|
||||
if (ev == MG_EV_OPEN && c->is_listening) {
|
||||
mg_timer_add(c->mgr, 1000, MG_TIMER_REPEAT, timer_func, c->mgr);
|
||||
mg_timer_add(c->mgr, 1000, MG_TIMER_REPEAT, timer_metrics_fn, c->mgr);
|
||||
mg_timer_add(c->mgr, 1000, MG_TIMER_REPEAT, timer_mqtt_fn, c->mgr);
|
||||
s_config.url = strdup(MQTT_SERVER);
|
||||
s_config.pub = strdup(MQTT_PUBLISH_TOPIC);
|
||||
s_config.sub = strdup(MQTT_SUBSCRIBE_TOPIC);
|
||||
} else if (ev == MG_EV_HTTP_MSG) {
|
||||
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
|
||||
struct user *u = getuser(hm);
|
||||
@ -100,14 +127,16 @@ void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
|
||||
mg_printf(c, "%s", "HTTP/1.1 403 Denied\r\nContent-Length: 0\r\n\r\n");
|
||||
} else if (mg_http_match_uri(hm, "/api/config/get")) {
|
||||
mg_printf(c, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n");
|
||||
mg_http_printf_chunk(c, "{\"%s\":%d,\"%s\":\"%s\"}", "value1",
|
||||
s_config.value1, "value2", s_config.value2);
|
||||
mg_http_printf_chunk(c, "{\"url\":\"%s\",\"pub\":\"%s\",\"sub\":\"%s\"}",
|
||||
s_config.url, s_config.pub, s_config.sub);
|
||||
mg_http_printf_chunk(c, "");
|
||||
} else if (mg_http_match_uri(hm, "/api/config/set")) {
|
||||
// Admins only
|
||||
if (strcmp(u->name, "admin") == 0) {
|
||||
if (update_config(hm, &s_config))
|
||||
send_notification(fn_data, "config", "null");
|
||||
update_config(&hm->body, "url", &s_config.url);
|
||||
update_config(&hm->body, "pub", &s_config.pub);
|
||||
update_config(&hm->body, "sub", &s_config.sub);
|
||||
send_notification(fn_data, "config", "null");
|
||||
mg_printf(c, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
|
||||
} else {
|
||||
mg_printf(c, "%s", "HTTP/1.1 403 Denied\r\nContent-Length: 0\r\n\r\n");
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -5,10 +5,8 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="chartist.min.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body></body>
|
||||
<script src="chartist.min.js"></script>
|
||||
<script type="module" src="main.js"></script>
|
||||
</html>
|
||||
|
@ -1,6 +1,8 @@
|
||||
'use strict';
|
||||
import {Component, h, html, render, useEffect, useState, useRef} from './preact.min.js';
|
||||
|
||||
const MaxMetricsDataPoints = 50;
|
||||
|
||||
const Nav = props => html`
|
||||
<div style="background: #333; padding: 0.5em; color: #fff;">
|
||||
<div class="container d-flex">
|
||||
@ -28,93 +30,77 @@ const Hero = props => html`
|
||||
<div style="margin-top: 1em; background: #eee; padding: 1em; border-radius: 0.5em; color: #777; ">
|
||||
<h1 style="margin: 0.2em 0;">Interactive Device Dashboard</h1>
|
||||
|
||||
<p>
|
||||
This device dashboard is developed with modern and compact Preact framework,
|
||||
in order to fit on a very small devices. This is
|
||||
a <a href="https://mongoose.ws/tutorials/http-server/">hybrid server</a> which
|
||||
provides both static and dynamic content. Static files, like CSS/JS/HTML
|
||||
or images, are compiled into the server binary. Dynamic content is
|
||||
served via the REST API.
|
||||
or images, are compiled into the server binary.
|
||||
|
||||
This dashboard shows values kept in server memory. Many clients can open
|
||||
this page. The JS code that watches state changes, reconnects on network
|
||||
failures. That means if server restarts, dashboard on all connected clients
|
||||
refresh automatically.
|
||||
|
||||
NOTE: administrators can change settings values, whilst users cannot.
|
||||
|
||||
<p>
|
||||
This UI uses the REST API implemented by the server, which you can examine
|
||||
This UI uses the REST API implemented by the device, which you can examine
|
||||
using <code>curl</code> command-line utility:
|
||||
</p>
|
||||
|
||||
<div><code>curl localhost:8000/api/config/get</code> - get current device configuration</div>
|
||||
<div><code>curl localhost:8000/api/config/set -d 'value1=7&value2=hello'</code> - set device configuration</div>
|
||||
<div><code>curl localhost:8000/api/message/send -d 'msg=hello'</code> - send chat message</div>
|
||||
<div><code>curl localhost:8000/api/watch</code> - get notifications: chat, config, metrics</div>
|
||||
<div><code>curl localhost:8000/api/message/send -d 'msg=hello'</code> - send MQTT message</div>
|
||||
<div><code>curl localhost:8000/api/watch</code> - get notifications: MQTT messages, configuration, sensor data</div>
|
||||
|
||||
</div>`;
|
||||
|
||||
const ShowSettings = function(props) {
|
||||
return html`
|
||||
<div style="margin: 0 0.3em;">
|
||||
<h3 style="background: #59d; color: #fff;padding: 0.4em;">Device configuration</h3>
|
||||
<div style="margin: 0.5em 0;">
|
||||
<span class="addon">value1:</span>
|
||||
<input disabled type="text" class="smooth"
|
||||
value=${(props.config || {}).value1 || ''} />
|
||||
</div>
|
||||
<div style="margin: 0.5em 0;">
|
||||
<span class="addon">value2:</span>
|
||||
<input disabled type="text" class="smooth"
|
||||
value=${(props.config || {}).value2 || ''} />
|
||||
</div>
|
||||
<div class="msg">
|
||||
Server's corresponding runtime configuration structure:
|
||||
<pre>
|
||||
struct config {
|
||||
int value1;
|
||||
char *value2;
|
||||
} s_config;
|
||||
</pre>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const ChangeSettings = function(props) {
|
||||
const [value1, setValue1] = useState('');
|
||||
const [value2, setValue2] = useState('');
|
||||
const Configuration = function(props) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [pub, setPub] = useState('');
|
||||
const [sub, setSub] = useState('');
|
||||
useEffect(() => {
|
||||
setValue1(props.config.value1);
|
||||
setValue2(props.config.value2);
|
||||
}, [props.config.value1, props.config.value2])
|
||||
setUrl(props.config.url);
|
||||
setPub(props.config.pub);
|
||||
setSub(props.config.sub);
|
||||
}, [props.config.url, props.config.pub, props.config.sub])
|
||||
const update = (name, val) => fetch('/api/config/set', {
|
||||
method: 'post',
|
||||
body: `${name}=${encodeURIComponent(val)}`
|
||||
}).catch(err => err);
|
||||
const updatevalue1 = ev => update('value1', value1);
|
||||
const updatevalue2 = ev => update('value2', value2);
|
||||
const updateurl = ev => update('url', url);
|
||||
const updatepub = ev => update('pub', pub);
|
||||
const updatesub = ev => update('sub', sub);
|
||||
return html`
|
||||
<div style="margin: 0 0.3em;">
|
||||
<h3 style="background: #c03434; color: #fff; padding: 0.4em;">
|
||||
Change configuration</h3>
|
||||
<div style="margin: 0.5em 0;">
|
||||
<span class="addon">value1:</span>
|
||||
<input type="text" value=${value1} onchange=${updatevalue1}
|
||||
oninput=${ev => setValue1(ev.target.value)} />
|
||||
<button class="btn" disabled=${!value1} onclick=${updatevalue1}
|
||||
Device Configuration</h3>
|
||||
<div style="margin: 0.5em 0; display: flex;">
|
||||
<span class="addon nowrap">MQTT server:</span>
|
||||
<input type="text" style="flex: 1 100%;"
|
||||
value=${url} onchange=${updateurl}
|
||||
oninput=${ev => setUrl(ev.target.value)} />
|
||||
<button class="btn" disabled=${!url} onclick=${updateurl}
|
||||
style="margin-left: 1em; background: #8aa;">Update</button>
|
||||
</div>
|
||||
<div style="margin: 0.5em 0;">
|
||||
<span class="addon">value2:</span>
|
||||
<input type="text" value=${value2} onchange=${updatevalue2}
|
||||
oninput=${ev => setValue2(ev.target.value)} />
|
||||
<button class="btn" disabled=${!value2} onclick=${updatevalue2}
|
||||
<div style="margin: 0.5em 0; display: flex; ">
|
||||
<span class="addon nowrap">Subscribe topic:</span>
|
||||
<input type="text" style="flex: 1 100%;"
|
||||
value=${sub} onchange=${updatesub}
|
||||
oninput=${ev => setSub(ev.target.value)} />
|
||||
<button class="btn" disabled=${!sub} onclick=${updatesub}
|
||||
style="margin-left: 1em; background: #8aa;">Update</button>
|
||||
</div>
|
||||
<div style="margin: 0.5em 0; display: flex;">
|
||||
<span class="addon nowrap">Publish topic:</span>
|
||||
<input type="text" style="flex: 1 100%;"
|
||||
value=${pub} onchange=${updatepub}
|
||||
oninput=${ev => setPub(ev.target.value)} />
|
||||
<button class="btn" disabled=${!pub} onclick=${updatepub}
|
||||
style="margin-left: 1em; background: #8aa;">Update</button>
|
||||
</div>
|
||||
<div class="msg">
|
||||
As soon as administrator updates configuration,
|
||||
server iterates over all connected clients and sends update
|
||||
notifications to all of them - so they update automatically.
|
||||
Device keeps a persistent connection to the configured MQTT server.
|
||||
Changes to this configuration are propagated to all dashboards: try
|
||||
changing them in this dashboard and observe changes in the other opened
|
||||
dashboard.
|
||||
</div><div class="msg">
|
||||
Note: administrators can see this section and can change device
|
||||
configuration, whilst users cannot.
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
@ -155,7 +141,7 @@ const Login = function(props) {
|
||||
|
||||
const Message = text => html`<div style="margin: 0.5em 0;">${text}</div>`;
|
||||
|
||||
const Chat = function(props) {
|
||||
const Messages = function(props) {
|
||||
const [message, setMessage] = useState('');
|
||||
const sendmessage = ev => fetch('/api/message/send', {
|
||||
method: 'post',
|
||||
@ -166,40 +152,105 @@ const Chat = function(props) {
|
||||
text => html`<div style="margin: 0.5em 0;">${text}</div>`);
|
||||
return html`
|
||||
<div style="margin: 0 0.3em;">
|
||||
<h3 style="background: #30c040; color: #fff; padding: 0.4em;">User chat</h3>
|
||||
<h3 style="background: #30c040; color: #fff; padding: 0.4em;">MQTT messages</h3>
|
||||
<div style="height: 10em; overflow: auto; padding: 0.5em; " class="border">
|
||||
${messages}
|
||||
</div>
|
||||
<div style="margin: 0.5em 0;">
|
||||
<input placeholder="type message..." style="width: 100%" value=${message}
|
||||
onchange=${sendmessage}
|
||||
<div style="margin: 0.5em 0; display: flex">
|
||||
<span class="addon nowrap">Publish message:</span>
|
||||
<input placeholder="type and press enter..." style="flex: 1 100%;"
|
||||
value=${message} onchange=${sendmessage}
|
||||
oninput=${ev => setMessage(ev.target.value)} />
|
||||
</div>
|
||||
<div class="msg">
|
||||
Chat demonsrates
|
||||
real-time bidirectional data exchange between many clients and a server.
|
||||
Message gets passed to the device via REST. Then a device sends it to
|
||||
the MQTT server over MQTT. All MQTT messages on a subscribed topic
|
||||
received by a device, are propagated to this dashboard via the
|
||||
/api/watch.
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const datefmt = unix => (new Date(unix * 1000)).toISOString().substr(14, 5);
|
||||
// Expected arguments:
|
||||
// data: timeseries, e.g. [ [1654361352, 19], [1654361353, 18], ... ]
|
||||
// width, height, yticks, xticks, ymin, ymax, xmin, xmax
|
||||
const SVG = function(props) {
|
||||
// w
|
||||
// +---------------------+
|
||||
// | h1 |
|
||||
// | +-----------+ |
|
||||
// | | | | h
|
||||
// | w1 | | w2 |
|
||||
// | +-----------+ |
|
||||
// | h2 |
|
||||
// +---------------------+
|
||||
//
|
||||
let w = props.width, h = props.height, w1 = 30, w2 = 0, h1 = 8, h2 = 18;
|
||||
let yticks = props.yticks || 4, xticks = props.xticks || 5;
|
||||
let data = props.data || [];
|
||||
let ymin = props.ymin || 0;
|
||||
let ymax = props.ymax || Math.max.apply(null, data.map(p => p[1]));
|
||||
let xmin = props.xmin || Math.min.apply(null, data.map(p => p[0]));
|
||||
let xmax = props.xmax || Math.max.apply(null, data.map(p => p[0]));
|
||||
|
||||
// Y-axis tick lines and labels
|
||||
let yta = (new Array(yticks + 1)).fill(0).map((_, i) => i); // indices
|
||||
let yti = i => h - h2 - (h - h1 - h2) * i / yticks; // index's Y
|
||||
let ytv = i => (ymax - ymin) * i / yticks;
|
||||
let ytl = y => html`<line x1=${w1} y1=${y} x2=${w} y2=${y} class="tick"/>`;
|
||||
let ytt = (y, v) => html`<text x=0 y=${y + 5} class="label">${v}</text>`;
|
||||
|
||||
// X-axis tick lines and labels
|
||||
let datefmt = unix => (new Date(unix * 1000)).toISOString().substr(14, 5);
|
||||
let xta = (new Array(xticks + 1)).fill(0).map((_, i) => i); // indices
|
||||
let xti = i => w1 + (w - w1 - w2) * i / xticks; // index's X
|
||||
let xtv = i => datefmt(xmin + (xmax - xmin) * i / xticks);
|
||||
let xtl = x => html`<path d="M ${x},${h1} L ${x},${h - h2}" class="tick"/>`;
|
||||
let xtt = (x, v) =>
|
||||
html`<text x=${x - 15} y=${h - 2} class="label">${v}</text>`;
|
||||
|
||||
// Transform data points array into coordinate
|
||||
let dx = v => w1 + (v - xmin) / ((xmax - xmin) || 1) * (w - w1 - w2);
|
||||
let dy = v => h - h2 - (v - ymin) / ((ymax - ymin) || 1) * (h - h1 - h2);
|
||||
let dd = data.map(p => [Math.round(dx(p[0])), Math.round(dy(p[1]))]);
|
||||
let ddl = dd.length;
|
||||
// And plot the data as <path> element
|
||||
let begin0 = ddl ? `M ${dd[0][0]},${dd[0][1]}` : `M 0,0`;
|
||||
let begin = `M ${w1},${h - h2}`; // Initial point
|
||||
let end = ddl ? `L ${dd[ddl - 1][0]},${h - h2}` : `L ${w1},${h - h2}`;
|
||||
let series = ddl ? dd.map(p => `L ${p[0]} ${p[1]}`) : [];
|
||||
|
||||
return html`
|
||||
<svg viewBox="0 0 ${w} ${h}">
|
||||
<style>
|
||||
.axis { stroke: #aaa; fill: none; }
|
||||
.label { stroke: #aaa; font-size: 13px; }
|
||||
.tick { stroke: #ccc; fill: none; stroke-dasharray: 5; }
|
||||
.seriesbg { stroke: none; fill: rgba(200,225,255, 0.25)}
|
||||
.series { stroke: #25a; fill: none; }
|
||||
</style>
|
||||
${yta.map(i => ytl(yti(i)))}
|
||||
${yta.map(i => ytt(yti(i), ytv(i)))}
|
||||
${xta.map(i => xtl(xti(i)))}
|
||||
${data.length ? xta.map(i => xtt(xti(i), xtv(i))) : ''}
|
||||
<path d="${begin} ${series.join(' ')} ${end}" class="seriesbg" />
|
||||
<path d="${begin0} ${series.join(' ')}" class="series" />
|
||||
</svg>`;
|
||||
};
|
||||
|
||||
|
||||
const Chart = function(props) {
|
||||
const chartdiv = useRef(null);
|
||||
const refresh = () => {
|
||||
const labels = props.metrics.map(el => datefmt(el[0]));
|
||||
const series = props.metrics.map(el => el[1]);
|
||||
const options = {low: 0, high: 20, showArea: true};
|
||||
// console.log([labels, [series]]);
|
||||
new Chartist.Line(chartdiv.current, {labels, series: [series]}, options);
|
||||
};
|
||||
useEffect(() => {chartdiv && refresh()}, [props.metrics]);
|
||||
|
||||
let xmax = 0, missing = MaxMetricsDataPoints - props.metrics.length;
|
||||
if (missing > 0) xmax = Math.round(Date.now() / 1000) + missing;
|
||||
return html`
|
||||
<div style="margin: 0 0.3em;">
|
||||
<h3 style="background: #ec3; color: #fff; padding: 0.4em;">Data Chart</h3>
|
||||
<div style="height: 14em; overflow: auto; padding: 0.5em; " class="border">
|
||||
<div ref=${chartdiv} style="height: 100%;" />
|
||||
<div style="overflow: auto; padding: 0.5em;" class="">
|
||||
<${SVG} height=240 width=600 ymin=0 ymax=20
|
||||
xmax=${xmax} data=${props.metrics} />
|
||||
</div>
|
||||
<div class="msg">
|
||||
This chart plots live sensor data, sent by a device via /api/watch.
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
@ -235,7 +286,7 @@ const App = function(props) {
|
||||
} else if (msg.name == 'message') {
|
||||
setMessages(m => m.concat([msg.data]));
|
||||
} else if (msg.name == 'metrics') {
|
||||
setMetrics(m => m.concat([msg.data]).splice(-10));
|
||||
setMetrics(m => m.concat([msg.data]).splice(-MaxMetricsDataPoints));
|
||||
}
|
||||
// console.log(msg);
|
||||
if (!result.done) return f(reader);
|
||||
@ -258,16 +309,15 @@ const App = function(props) {
|
||||
|
||||
if (!user) return html`<${Login} login=${login} />`;
|
||||
const admin = user == 'admin';
|
||||
const cs = admin ? html`<${ChangeSettings} config=${config} />` : '';
|
||||
const cs = admin ? html`<${Configuration} config=${config} />` : '';
|
||||
return html`
|
||||
<${Nav} user=${user} logout=${logout} />
|
||||
<div class="container">
|
||||
<${Hero} />
|
||||
<div class="row">
|
||||
<div class="col c6"><${Chart} metrics=${metrics} /></div>
|
||||
<div class="col c6"><${ShowSettings} config=${config} /></div>
|
||||
<div class="col c6"><${Chat} messages=${messages} /></div>
|
||||
<div class="col c6">${cs}</div>
|
||||
<div class="col c6"><${Messages} messages=${messages} /></div>
|
||||
</div>
|
||||
<${Footer} />
|
||||
</div>`;
|
||||
|
@ -4,7 +4,7 @@ select, input, label::before, textarea { outline: none; box-shadow:none !importa
|
||||
code, pre { color: #373; font-family: monospace; font-weight: bolder; font-size: smaller; background: #ddd; padding: 0.1em 0.3em; border-radius: 0.2em; }
|
||||
textarea, input, .addon { font-size: 15px; border: 1px solid #ccc; padding: 0.5em; }
|
||||
a, a:visited, a:active { color: #55f; }
|
||||
.addon { background: #eee; }
|
||||
.addon { background: #eee; min-width: 9em;}
|
||||
.btn {
|
||||
background: #ccc; border-radius: 0.3em; border: 0; color: #fff; cursor: pointer;
|
||||
display: inline-block; padding: 0.6em 2em; font-weight: bolder;
|
||||
@ -16,6 +16,7 @@ a, a:visited, a:active { color: #55f; }
|
||||
.d-none { display: none; }
|
||||
.border { border: 1px solid #ddd; }
|
||||
.rounded { border-radius: 0.5em; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.msg { background: #def; border-left: 5px solid #59d; padding: 1em; margin: 1em 0; }
|
||||
.row { margin: 1% 0; overflow: auto; }
|
||||
.col { float: left; }
|
||||
|
Loading…
Reference in New Issue
Block a user