Deployment

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 data

All 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:

access:
  require_login: true

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 = "[email protected]",
  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/dbname DSN, built with episodic_db_dsn_mariadb() rather than assembled by hand. Requires the RMariaDB package. 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>.bak before 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:

database:
  auto_migrate: false

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