This vignette is for standing up a real instance. If you only want to
see the system working, run EpiSODIC::episodic_demo()
instead - no data, no credentials, no configuration required.
The two things you provide
EpiSODIC never connects to a laboratory information system, data warehouse, or any other data source itself - that step is deliberately yours, run before EpiSODIC, so the package stays reusable by any laboratory rather than tied to one:
cases <- my_extract_and_transform_function() # a data frame or tibble
episodic_run_cron(
db_path = "/path/to/episodic.sqlite",
cases = cases,
denominators = NULL # optional
)cases (and denominators,
institution_activity) is normally a plain data frame or
tibble, as above - that is what these arguments are written for. If
producing the data only makes sense at run time (a live database query,
for instance), a zero-argument function returning one is accepted just
as well - see episodic_resolve_data(). Getting
your data in documents the exact columns each of the four
data sources (cases, positivity metadata, institution activity,
geographic reference data) expects; only cases are mandatory.
Scope my_extract_and_transform_function() to a recent
window - the last few weeks, with a couple of weeks of overlap as a
margin against a missed run - rather than a patient’s full history on
every schedule. A scheduled job should stay fast and light regardless of
how large the archive behind it grows. This is safe: deduplication
checks every incoming result against what is already stored for that
patient and pathogen, so a result that reappears inside your overlap
window, or one that continues an episode whose earlier result is not in
this batch at all, is still recognised correctly rather than counted
twice.
Check your extract against those requirements before you schedule anything:
episodic_check_cases(cases)
episodic_check_denominators(denominators) # if you supply positivity data
episodic_check_institution_activity(institution_activity) # if you supply activity dataAll three need no database and change nothing, and report everything
wrong with the data at once - the column, the number of rows affected,
which rows those are, the offending values, and what to do about each -
along with what is merely worth a look (one pathogen spelled two ways,
no ward on any hospital row, a patient_key
that never repeats). Setting
episodic_check_cases(cases, stop_on_problem = TRUE) makes
the checks return an error if there is at least one problem, for use in
a script. episodic_run_cron() runs that before every
run.
Schedule episodic_run_cron() however your environment
normally schedules R jobs (cron, a Windows scheduled task, a CI
pipeline) - there is nothing EpiSODIC-specific about the scheduling
itself. It validates the case data itself before writing anything: a run
on data that does not satisfy the requirements stops with that same
message, records it on the run row, and leaves the database untouched,
so a scheduled job fails visibly (a non-zero exit, an error in the job’s
log) rather than completing over data it could not read. The recorded
message is what the dashboard’s status strip and activity screen show,
so whoever notices the empty dashboard first learns why without going
near a log file.
Configuration lives outside the repository
Detection thresholds, baseline lengths, same_place rules
and MEM seasons are operational data, not software, and are
never committed to this repository. inst/config/episodic_default_config.yaml
ships the documented defaults; point EPISODIC_CONFIG at a
YAML file with only the keys you want to override, and it is merged
key-by-key on top of the shipped defaults. Every run records the
resolved configuration’s hash and full snapshot on
episodic_detection_run, so the exact parameters behind any
past result stay recoverable from the database alone, regardless of what
has since changed on disk.
The UI’s colour palette and typography work the same way,
deliberately through a separate environment variable
(EPISODIC_STYLE): colour and font choice are display
concerns, never part of the detection-reproducibility guarantee
EPISODIC_CONFIG’s hash provides.
Accounts
By default the app is open: an anonymous visitor can
read the aggregate screens - outbreaks, epidemics, streams, the archive
- without signing in, and needs an account only for the line list and
for writing. Accounts are never created by users themselves; either an
is_admin account provisions them from the in-app Settings
screen, or whoever administers the database runs
episodic_add_user() at the console. Signing in unlocks
everything the account’s role allows, patient-level detail (the line
list) included, for both roles below.
Closing the app to anonymous visitors
A cluster row names a pathogen, a ward or an institution, and a date
range; in a small population that combination is often enough to
identify who, even without the line list. An instance that is reachable
by anyone other than the colleagues it is meant for - rather than
sitting behind network controls that already make that impossible -
should close that gap rather than rely on the open default. Set
access.require_login to true in your
EPISODIC_CONFIG YAML:
With require_login: false (the shipped default), an
instance is already behind network controls that make anonymous
reachability impossible often enough that closing it too would just be a
second sign-in nobody needed; weigh the setting deliberately for your
own deployment rather than leaving it at either value by habit.
With require_login: true:
An anonymous visitor gets a sign-in prompt and nothing else: no navigation, no status strip, not one row of data. This is enforced on the server, not in the browser - none of those screens is rendered for a session that has not signed in, so nothing they would contain is ever serialised into the page. There is correspondingly nothing for a browser’s developer tools to uncover by removing the prompt: what is not sent cannot be inspected. The same gate covers inputs a client can set for itself, not only the links the navigation offers.
This is a YAML-only setting on purpose, with no override on the Settings screen: a login wall an admin account can switch off from inside the app is a login wall that falls with that one account. Turning it off takes file access to the machine. The Settings screen does report which way it is set, so an admin can confirm it without SSH access.
It is excluded from config_hash alongside
notifications: it governs how the instance is operated,
never what a detection run computes, so turning it on does not make
historic runs look incomparable with current ones.
There are exactly two roles:
-
"epidemiologist"- by definition, assesses clusters and classifies them. Can do everything a viewer can, plus classify, close, and mute clusters, and re-render reports on demand. -
"viewer"- read-only. Sees exactly what a signed-in epidemiologist sees, including patient-level detail, but cannot record an assessment.
Independent of role, an account can additionally be flagged
is_admin, which unlocks the Settings screen: managing
notification channels and recipients, creating accounts and changing
their role/admin/active state, and exporting the resolved configuration.
Detection parameters themselves stay read-only there deliberately -
changing them is still a EPISODIC_CONFIG YAML change, so
every past result stays traceable to the exact configuration that
produced it (see “Configuration” above).
Sys.setenv(EPISODIC_DB = "/path/to/episodic.sqlite") # or pass db_path explicitly below
episodic_add_user(
username = "jdoe",
role = "epidemiologist", # or "viewer"
full_name = "Dr Jane Doe",
email = "j.doe@example.org",
password = "a-temporary-password"
)episodic_add_user() takes db_path
(defaulting to EPISODIC_DB, see Environment
variables), not an open connection - it opens and closes
its own via episodic_db_open(), so adding an account is one
call at the console. The account is created with
must_change = TRUE, so its first real sign-in forces the
holder to set their own password before continuing. Pass
is_admin = TRUE to give the new account Settings-screen
access as well - you need at least one such account to manage anything
from the dashboard itself; every account after that can be added either
way.
Watching sign-ins
Both outcomes are recorded. A successful sign-in becomes a
login event on the account; a refused one is written to
episodic_app_login_failure with the username as typed and
which of three reasons it was - no account with that username, wrong
password, or account deactivated. The sign-in screen itself still says
none of that to whoever is typing: unknown username and wrong password
are one generic refusal to them, so that a stranger cannot use the login
form to find out which usernames exist. The distinction is for the
operator, in the record, afterwards.
Both appear on the Activity screen, where the filter chips at the top narrow the log to sign-ins alone. A run of refused attempts on one account, or a series of usernames nobody has, is then visible the same day it happens.
Sign-in rows are the one part of the Activity screen withheld from a
reader who has not signed in - on an instance running with
require_login: false, “who has an account here, and which
usernames somebody has been trying” is precisely what not to hand a
visitor.
This is a record, not a defence: EpiSODIC has no account lockout and no rate limiting, and deliberately so - a surveillance dashboard that locks an epidemiologist out mid-outbreak because somebody else mistyped their username is worse than the attack it prevents. Keeping attackers off the port is your network’s job (see the top of this vignette); noticing that one got as far as the login form is this log’s.
Where the database lives
EPISODIC_DB (and every db_path argument
that falls back to it) works against two backends:
- SQLite (the default): a path to a local file. It must sit on local disk, not in a synchronising folder (SharePoint, OneDrive, Dropbox) - background sync can corrupt or fork a live SQLite file without any immediate warning. Configuration files are safe in a synced location, since they are small and read-only at runtime; the database itself is not.
-
MariaDB/MySQL: a
mysql://user:password@host:port/dbnameDSN, built withepisodic_db_dsn_mariadb()rather than assembled by hand. Requires theRMariaDBpackage. This is the option for a deployment that already runs a database server and would rather point EpiSODIC at it than manage a SQLite file on disk.
Sys.setenv(EPISODIC_DB = episodic_db_dsn_mariadb(
host = "db.internal", dbname = "episodic",
user = "episodic_app", password = "s3cr3t!"
))Connecting to MariaDB/MySQL requires the RMariaDB
package (install.packages("RMariaDB")); it is a
Suggests dependency, not installed automatically, so
SQLite-only deployments never need it. The schema (inst/sql/schema.sql)
is written once, in SQLite syntax, and adapted at load time for what
differs under MariaDB/MySQL - there is no separate schema file to keep
in sync.
Which servers this is tested against
Every release runs the full test suite against a real MariaDB 11 and a real MySQL 8 server, not only against SQLite, and a schema change either server rejects fails the build. The suite has also been run by hand against MariaDB 10.11.
Both are tested because they are not the same server. MariaDB and MySQL disagree about how a foreign key written on the column rather than at the end of the table should be treated - MariaDB creates it, MySQL parses it and discards it - and EpiSODIC’s schema is written in the form SQLite wants. It is translated for these servers so that the constraints exist on all three, and the suite counts them on a live server rather than assuming.
CHECK constraints are enforced from MariaDB 10.2.1 /
MySQL 8.0.16 onwards; on older servers they are accepted but silently
ignored.
Sharing a schema with something else
EpiSODIC does not need a database of its own. Its tables can sit in a
schema alongside another application’s, which is a common way to be
given a database by an infrastructure team, and every operation that
enumerates tables - creating the schema, overwrite = TRUE,
and episodic_db_truncate() - is scoped to EpiSODIC’s own
tables and cannot touch a co-tenant’s.
If you already have a MariaDB or MySQL database
One created by EpiSODIC 0.15.x or earlier has no foreign
keys. They were declared in the schema and the server discarded
them without saying so, so such a database has been accepting rows that
reference nothing. There is no migration that adds them, because a
constraint cannot be added to a table that already violates it: recreate
the database with episodic_db_create(overwrite = TRUE) and
load your case data again. SQLite databases are unaffected and always
had them.
To check what your server actually created:
SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME LIKE 'episodic\_%';Anything other than 56 means the schema and the server disagree, and the suite’s own live tests check that same number against a live server on every run.
Upgrading EpiSODIC
The database records which version of EpiSODIC’s schema it was built
with. When an upgraded package meets a database whose schema is behind
it, the next episodic_run_cron() brings the database
forward itself, before it does anything else, and says so in its
log:
Database migrated from schema version 8 to schema version 9
So upgrading an instance is upgrading the package:
update.packages(), a rebuilt container image, or a
colleague’s install.packages(), and the next scheduled run
does the rest. Nothing has to be remembered on the day, which matters
most for the instance nobody looks after closely.
What a migration does, and does not do
- It never deletes data. Migrations add tables, add columns and backfill values, and never rewrite anything. A column is dropped only when EpiSODIC no longer uses it and it holds no value on any row; a column holding data is kept, and the log says so. Your surveillance history, your assessments and your audit trail are carried forward untouched.
- It runs before the run. Each step is a transaction of its own, applied before the run writes anything, never inside the detection run’s own transaction.
-
It copies a SQLite database first. The file is
copied beside itself as
<file>.schema-<from>-to-<to>.bakbefore the first step, and the log names the copy. If a copy with that name already exists from an earlier attempt at the same upgrade, it is kept rather than replaced. A MariaDB or MySQL database is not copied, because a server’s backups belong to whoever runs the server; the log says so. -
One process migrates at a time. A scheduled run and
a manual
episodic_db_migrate(), or two servers sharing one MariaDB schema, can meet the same out-of-date database at once. One of them migrates, the other waits, finds the work done, and carries on. - Old databases are adopted. A database built by EpiSODIC 0.12.x or earlier carries no version at all, since the version table postdates it. It is adopted at version 1 and brought forward from there.
- Skipped versions are fine. An instance that skips versions (0.19 straight to 0.25) is brought through every step in between, in order.
When the schema is not the application’s to change
Many hospitals do not let an application change its own database
schema: the account EpiSODIC connects with may read and write rows but
not CREATE or ALTER, and schema changes go
through a DBA and change control. For such an instance, turn the
automatic migration off in your EPISODIC_CONFIG YAML:
A run that then meets a database behind the package does not start. The refusal is:
- printed in the run log, naming both schema versions and the command to run;
- recorded as a failed run, so the gap is explained on the Activity screen once the database has been migrated.
Until then, the dashboard of the upgraded package cannot open the database either: it refuses it with the same explanation rather than reading a schema it does not know.
Whoever holds the privileges then runs, from an account that has
CREATE, ALTER, INDEX and
REFERENCES on the database:
episodic_db_migrate() # or episodic_db_migrate(db_path)If auto_migrate is left on and the account lacks those
privileges, the run fails the same way, and the message says which
privileges are missing rather than passing on the server’s
CREATE command denied.
Under MariaDB and MySQL, a schema change commits on its own, so a step that fails part-way may leave part of itself in place. Every step skips what it finds already done, so running the migration again once the cause is fixed is the correct response.
Going back
Migrations only go forward. A package older than its database refuses to open it, and tells you to upgrade the package - or, if the upgrade is already installed, to restart the R process still running the old one. A dashboard started before the upgrade is the usual case: the scheduled run has migrated the database under it.
To really go back, restore the SQLite copy taken before the migration, or your own MariaDB/MySQL backup.
Running the app
Sys.setenv(EPISODIC_DB = "/path/to/episodic.sqlite")
episodic_run_app()episodic_run_app()’s db_path argument
defaults to EPISODIC_DB exactly like
episodic_add_user()’s does, so a systemd unit or Docker
container can configure everything through environment variables alone,
with no R code to edit between instances.
Behind a reverse proxy
The dashboard holds its connection open as a WebSocket, not a
sequence of ordinary HTTP requests, so a proxy in front of it needs the
Upgrade handshake forwarded, not only /. Give
that connection a generous idle timeout too: an epidemiologist reading
one dossier for minutes sends almost nothing over the socket in the
meantime, and a timeout sized for ordinary HTTP traffic can drop a quiet
session and grey out the page while the R process behind it is still
running.
Custom report templates
episodic_report_render() renders the shipped inst/report/episodic_default_report.qmd
(embedded as self-contained HTML via Quarto) unless
EPISODIC_QUARTO_REPORT points at an operator’s own
.qmd file, in which case that is used instead - for an
organisation that wants its own letterhead, section order, or house
style. A custom template only needs to
readRDS(params$data_path) and read from the same list the
shipped one does (obj, epi_curve,
trend, linelist, timeline,
similar, diff,
small_count_threshold, rendered_at,
lang, palette, package_version);
see the shipped template for the exact shape, including how it calls
episodic_tr(..., lang = d$lang) to render in any of the
dashboard’s supported languages. diff is NULL
for a cluster’s first-ever render and otherwise holds what changed since
the previous version - see Scheduled
reports’s “What changed since last time” section for what
it contains.
The report is styled from the instance palette, the same colours and
font as the dashboard (EPISODIC_STYLE). The process asking
for the render resolves the palette and hands it to the template as
palette; the shipped template passes it to
EpiSODIC:::episodic_palette_use() in its setup chunk, so
its charts are drawn in those colours, and names
episodic.scss as its theme - a Bootstrap theme built from
the palette and written beside the template for every render. A custom
template can do both. The font is named in the report’s stylesheet only:
a reader sees it if it is installed on their machine, and otherwise the
next font in the font stack.
Emails have templates of their own,
EPISODIC_MAIL_TEMPLATE_NEW_CLUSTERS and
EPISODIC_MAIL_TEMPLATE_REPORT; see Notifications’s
“Your own email layout”.
Scheduled reports
A cluster can be put on a recurring email schedule from its dossier -
every N days, to a list of colleagues without an EpiSODIC account. This
reuses the report template and the email-capable notification channels
(smtp, sendmail, microsoft365)
described in Notifications;
see Scheduled
reports for the full setup guide, including the cadence and
automatic-closure rules and how to extend a custom template with more
patient-level detail than the shipped one shows.
Optional pieces, and their fallbacks
Every optional integration point degrades to a documented fallback rather than failing when it is absent:
| Feature | Needs | Fallback |
|---|---|---|
| Rt estimation | EpiEstim |
Panel omitted |
| MEM seasonal thresholds | mem |
Detector skipped for pathogens where MEM cannot fit |
| Outbreak reports |
quarto R package + the separate Quarto CLI |
Render errors clearly instead of silently producing nothing |
| Choropleth map |
sf + geographic reference data |
Plain bar breakdown by PC value |
| Notifications |
httr2, curl, Microsoft365R +
AzureGraph + AzureAuth (depending on
channel) |
No alerts; review clusters through the dashboard only |
AMR is a hard dependency, not an optional integration:
episode deduplication (episodic_cases_deduplicate()) calls
AMR::get_episode() directly, and pathogen-name
italicisation (episodic_ui_italicise_taxon()) reads
AMR::microorganisms. There is no fallback for either, by
design - see the DESCRIPTION for the published methods this package is
built on.
House-style colours and typography are not an optional-dependency
concern at all: the app always ships an organisation-neutral default
palette (inst/config/episodic_default_style.yaml),
overridable per instance by pointing EPISODIC_STYLE at an
organisation’s own YAML file with its real colours and font - no package
dependency involved either way. A webfont named there still needs
delivering (self-hosted, or linked from its own provider), typically via
a custom www/episodic.css; a system font needs no such link
at all.
None of these are required to run the demo, the detection engine, or the interface - each is additive.
Notifications
EpiSODIC can notify your team, through ntfy, email, Teams, or Slack,
whenever new clusters are detected or a cron run fails. Notifications
are configured in the same YAML file as detection thresholds
(EPISODIC_CONFIG), under a notifications key.
See Notifications
for the full setup guide, with step-by-step instructions for every
supported channel.
See also
-
Getting
your data in for the case data requirements this vignette’s
episodic_run_cron()calls expect. -
Environment
variables for the full
EPISODIC_*reference table, including every variable named above. - Notifications for setting up alerts on new clusters and run failures.
- Scheduled reports for emailing a recurring outbreak report to colleagues without a dashboard account.
- Frequently asked questions for hosting choices, account roles, and other operational questions.