Change device dashboard to use MQTT

This commit is contained in:
Sergey Lyubka 2022-06-03 07:13:08 +01:00
parent f4fa1e097d
commit 219428c249
8 changed files with 1303 additions and 5350 deletions

View File

@ -1,5 +1,5 @@
PROG ?= example 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) CFLAGS ?= -I../.. -DMG_ENABLE_PACKED_FS=1 $(EXTRA)
FILES_TO_EMBED ?= $(wildcard web_root/*) FILES_TO_EMBED ?= $(wildcard web_root/*)
ROOT ?= $(realpath $(CURDIR)/../../..) ROOT ?= $(realpath $(CURDIR)/../../..)

View File

@ -3,6 +3,10 @@
#include "mongoose.h" #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. // Authenticated user.
// A user can be authenticated by: // A user can be authenticated by:
// - a name:pass pair // - a name:pass pair
@ -17,25 +21,18 @@ struct user {
// This is a configuration structure we're going to show on a dashboard // This is a configuration structure we're going to show on a dashboard
static struct config { static struct config {
int value1; char *url, *pub, *sub; // MQTT settings
char *value2; } s_config;
} s_config = {123, NULL};
// Update config structure. Return true if changed, false otherwise static struct mg_connection *s_mqtt = NULL; // MQTT connection
static bool update_config(struct mg_http_message *hm, struct config *cfg) {
bool changed = false; // Try to update a single configuration value
static void update_config(struct mg_str *body, const char *name, char **value) {
char buf[256]; char buf[256];
if (mg_http_get_var(&hm->body, "value1", buf, sizeof(buf)) > 0) { if (mg_http_get_var(body, name, buf, sizeof(buf)) > 0) {
cfg->value1 = atoi(buf); free(*value);
changed = true; *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 // 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 // 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]; char buf[50];
mg_snprintf(buf, sizeof(buf), "[ %lu, %d ]", (unsigned long) time(NULL), mg_snprintf(buf, sizeof(buf), "[ %lu, %d ]", (unsigned long) time(NULL),
10 + (int) ((double) rand() * 10 / RAND_MAX)); 10 + (int) ((double) rand() * 10 / RAND_MAX));
@ -83,11 +80,41 @@ static void timer_func(void *param) {
send_notification(param, "metrics", buf); 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 // HTTP request handler function
void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data, void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
void *fn_data) { void *fn_data) {
if (ev == MG_EV_OPEN && c->is_listening) { 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) { } else if (ev == MG_EV_HTTP_MSG) {
struct mg_http_message *hm = (struct mg_http_message *) ev_data; struct mg_http_message *hm = (struct mg_http_message *) ev_data;
struct user *u = getuser(hm); 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"); 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")) { } 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_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", mg_http_printf_chunk(c, "{\"url\":\"%s\",\"pub\":\"%s\",\"sub\":\"%s\"}",
s_config.value1, "value2", s_config.value2); s_config.url, s_config.pub, s_config.sub);
mg_http_printf_chunk(c, ""); mg_http_printf_chunk(c, "");
} else if (mg_http_match_uri(hm, "/api/config/set")) { } else if (mg_http_match_uri(hm, "/api/config/set")) {
// Admins only // Admins only
if (strcmp(u->name, "admin") == 0) { if (strcmp(u->name, "admin") == 0) {
if (update_config(hm, &s_config)) update_config(&hm->body, "url", &s_config.url);
send_notification(fn_data, "config", "null"); 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"); mg_printf(c, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
} else { } else {
mg_printf(c, "%s", "HTTP/1.1 403 Denied\r\nContent-Length: 0\r\n\r\n"); 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

View File

@ -5,10 +5,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="chartist.min.css" />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body></body> <body></body>
<script src="chartist.min.js"></script>
<script type="module" src="main.js"></script> <script type="module" src="main.js"></script>
</html> </html>

View File

@ -1,6 +1,8 @@
'use strict'; 'use strict';
import {Component, h, html, render, useEffect, useState, useRef} from './preact.min.js'; import {Component, h, html, render, useEffect, useState, useRef} from './preact.min.js';
const MaxMetricsDataPoints = 50;
const Nav = props => html` const Nav = props => html`
<div style="background: #333; padding: 0.5em; color: #fff;"> <div style="background: #333; padding: 0.5em; color: #fff;">
<div class="container d-flex"> <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; "> <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> <h1 style="margin: 0.2em 0;">Interactive Device Dashboard</h1>
<p>
This device dashboard is developed with modern and compact Preact framework, This device dashboard is developed with modern and compact Preact framework,
in order to fit on a very small devices. This is in order to fit on a very small devices. This is
a <a href="https://mongoose.ws/tutorials/http-server/">hybrid server</a> which 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 provides both static and dynamic content. Static files, like CSS/JS/HTML
or images, are compiled into the server binary. Dynamic content is or images, are compiled into the server binary.
served via the REST API.
This dashboard shows values kept in server memory. Many clients can open This UI uses the REST API implemented by the device, which you can examine
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
using <code>curl</code> command-line utility: using <code>curl</code> command-line utility:
</p> </p>
<div><code>curl localhost:8000/api/config/get</code> - get current device configuration</div> <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/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/message/send -d 'msg=hello'</code> - send MQTT message</div>
<div><code>curl localhost:8000/api/watch</code> - get notifications: chat, config, metrics</div> <div><code>curl localhost:8000/api/watch</code> - get notifications: MQTT messages, configuration, sensor data</div>
</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 Configuration = function(props) {
const [value1, setValue1] = useState(''); const [url, setUrl] = useState('');
const [value2, setValue2] = useState(''); const [pub, setPub] = useState('');
const [sub, setSub] = useState('');
useEffect(() => { useEffect(() => {
setValue1(props.config.value1); setUrl(props.config.url);
setValue2(props.config.value2); setPub(props.config.pub);
}, [props.config.value1, props.config.value2]) setSub(props.config.sub);
}, [props.config.url, props.config.pub, props.config.sub])
const update = (name, val) => fetch('/api/config/set', { const update = (name, val) => fetch('/api/config/set', {
method: 'post', method: 'post',
body: `${name}=${encodeURIComponent(val)}` body: `${name}=${encodeURIComponent(val)}`
}).catch(err => err); }).catch(err => err);
const updatevalue1 = ev => update('value1', value1); const updateurl = ev => update('url', url);
const updatevalue2 = ev => update('value2', value2); const updatepub = ev => update('pub', pub);
const updatesub = ev => update('sub', sub);
return html` return html`
<div style="margin: 0 0.3em;"> <div style="margin: 0 0.3em;">
<h3 style="background: #c03434; color: #fff; padding: 0.4em;"> <h3 style="background: #c03434; color: #fff; padding: 0.4em;">
Change configuration</h3> Device Configuration</h3>
<div style="margin: 0.5em 0;"> <div style="margin: 0.5em 0; display: flex;">
<span class="addon">value1:</span> <span class="addon nowrap">MQTT server:</span>
<input type="text" value=${value1} onchange=${updatevalue1} <input type="text" style="flex: 1 100%;"
oninput=${ev => setValue1(ev.target.value)} /> value=${url} onchange=${updateurl}
<button class="btn" disabled=${!value1} onclick=${updatevalue1} oninput=${ev => setUrl(ev.target.value)} />
<button class="btn" disabled=${!url} onclick=${updateurl}
style="margin-left: 1em; background: #8aa;">Update</button> style="margin-left: 1em; background: #8aa;">Update</button>
</div> </div>
<div style="margin: 0.5em 0;"> <div style="margin: 0.5em 0; display: flex; ">
<span class="addon">value2:</span> <span class="addon nowrap">Subscribe topic:</span>
<input type="text" value=${value2} onchange=${updatevalue2} <input type="text" style="flex: 1 100%;"
oninput=${ev => setValue2(ev.target.value)} /> value=${sub} onchange=${updatesub}
<button class="btn" disabled=${!value2} onclick=${updatevalue2} 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> style="margin-left: 1em; background: #8aa;">Update</button>
</div> </div>
<div class="msg"> <div class="msg">
As soon as administrator updates configuration, Device keeps a persistent connection to the configured MQTT server.
server iterates over all connected clients and sends update Changes to this configuration are propagated to all dashboards: try
notifications to all of them - so they update automatically. 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>
</div>`; </div>`;
}; };
@ -155,7 +141,7 @@ const Login = function(props) {
const Message = text => html`<div style="margin: 0.5em 0;">${text}</div>`; 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 [message, setMessage] = useState('');
const sendmessage = ev => fetch('/api/message/send', { const sendmessage = ev => fetch('/api/message/send', {
method: 'post', method: 'post',
@ -166,40 +152,105 @@ const Chat = function(props) {
text => html`<div style="margin: 0.5em 0;">${text}</div>`); text => html`<div style="margin: 0.5em 0;">${text}</div>`);
return html` return html`
<div style="margin: 0 0.3em;"> <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"> <div style="height: 10em; overflow: auto; padding: 0.5em; " class="border">
${messages} ${messages}
</div> </div>
<div style="margin: 0.5em 0;"> <div style="margin: 0.5em 0; display: flex">
<input placeholder="type message..." style="width: 100%" value=${message} <span class="addon nowrap">Publish message:</span>
onchange=${sendmessage} <input placeholder="type and press enter..." style="flex: 1 100%;"
value=${message} onchange=${sendmessage}
oninput=${ev => setMessage(ev.target.value)} /> oninput=${ev => setMessage(ev.target.value)} />
</div> </div>
<div class="msg"> <div class="msg">
Chat demonsrates Message gets passed to the device via REST. Then a device sends it to
real-time bidirectional data exchange between many clients and a server. 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>
</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 Chart = function(props) {
const chartdiv = useRef(null); let xmax = 0, missing = MaxMetricsDataPoints - props.metrics.length;
const refresh = () => { if (missing > 0) xmax = Math.round(Date.now() / 1000) + missing;
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]);
return html` return html`
<div style="margin: 0 0.3em;"> <div style="margin: 0 0.3em;">
<h3 style="background: #ec3; color: #fff; padding: 0.4em;">Data Chart</h3> <h3 style="background: #ec3; color: #fff; padding: 0.4em;">Data Chart</h3>
<div style="height: 14em; overflow: auto; padding: 0.5em; " class="border"> <div style="overflow: auto; padding: 0.5em;" class="">
<div ref=${chartdiv} style="height: 100%;" /> <${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>
</div>`; </div>`;
}; };
@ -235,7 +286,7 @@ const App = function(props) {
} else if (msg.name == 'message') { } else if (msg.name == 'message') {
setMessages(m => m.concat([msg.data])); setMessages(m => m.concat([msg.data]));
} else if (msg.name == 'metrics') { } else if (msg.name == 'metrics') {
setMetrics(m => m.concat([msg.data]).splice(-10)); setMetrics(m => m.concat([msg.data]).splice(-MaxMetricsDataPoints));
} }
// console.log(msg); // console.log(msg);
if (!result.done) return f(reader); if (!result.done) return f(reader);
@ -258,16 +309,15 @@ const App = function(props) {
if (!user) return html`<${Login} login=${login} />`; if (!user) return html`<${Login} login=${login} />`;
const admin = user == 'admin'; const admin = user == 'admin';
const cs = admin ? html`<${ChangeSettings} config=${config} />` : ''; const cs = admin ? html`<${Configuration} config=${config} />` : '';
return html` return html`
<${Nav} user=${user} logout=${logout} /> <${Nav} user=${user} logout=${logout} />
<div class="container"> <div class="container">
<${Hero} /> <${Hero} />
<div class="row"> <div class="row">
<div class="col c6"><${Chart} metrics=${metrics} /></div> <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">${cs}</div>
<div class="col c6"><${Messages} messages=${messages} /></div>
</div> </div>
<${Footer} /> <${Footer} />
</div>`; </div>`;

View File

@ -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; } 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; } textarea, input, .addon { font-size: 15px; border: 1px solid #ccc; padding: 0.5em; }
a, a:visited, a:active { color: #55f; } a, a:visited, a:active { color: #55f; }
.addon { background: #eee; } .addon { background: #eee; min-width: 9em;}
.btn { .btn {
background: #ccc; border-radius: 0.3em; border: 0; color: #fff; cursor: pointer; background: #ccc; border-radius: 0.3em; border: 0; color: #fff; cursor: pointer;
display: inline-block; padding: 0.6em 2em; font-weight: bolder; 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; } .d-none { display: none; }
.border { border: 1px solid #ddd; } .border { border: 1px solid #ddd; }
.rounded { border-radius: 0.5em; } .rounded { border-radius: 0.5em; }
.nowrap { white-space: nowrap; }
.msg { background: #def; border-left: 5px solid #59d; padding: 1em; margin: 1em 0; } .msg { background: #def; border-left: 5px solid #59d; padding: 1em; margin: 1em 0; }
.row { margin: 1% 0; overflow: auto; } .row { margin: 1% 0; overflow: auto; }
.col { float: left; } .col { float: left; }