Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- The interface font is now configurable: pick one at the bottom of the sidebar
(remembered per browser), or set the default for everyone with
`config.ui_font`. Public Sans (the new default), Barlow, General Sans, Inter,
Manrope, or the OS UI font.

### Changed

- Fonts are served as separate cached assets instead of being inlined in the
stylesheet, so a browser downloads only the face it renders in. The stylesheet
is 74% smaller (118KB → 31KB).

## [1.0.0] - 2026-08-11

### Changed
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Maintain `CHANGELOG.md` for user-visible changes — brief and simple: one line

**Auth** (`app/controllers/flightdeck/application_controller.rb`): the controller inherits from `Flightdeck.base_controller_class`. Unconfigured → plain-text 401 explaining the four setup options, in every environment. With `config.base_controller_class` set, the host's `before_action`s are the gate and Flightdeck's HTTP Basic is skipped. `AssetsController` is `ActionController::Metal` and deliberately unauthenticated; asset names are whitelisted against the manifest (`lib/flightdeck/assets.rb`) so no user input ever reaches the filesystem.

**Assets have no host-side pipeline.** Committed, digest-named artifacts live in `app/assets/flightdeck/` with `manifest.json`; sources in `assets-src/` (Tailwind v4 CSS, vendored Turbo/Stimulus, Stimulus controllers concatenated by `rake assets:build` — no node, no importmap). `assets-src/input.css` uses `@import "tailwindcss" source(none)` plus explicit `@source` directives: this makes the build byte-deterministic across machines (automatic source detection once scanned CI's `vendor/bundle` and changed the output). Do not remove those directives. New Stimulus controllers go in `assets-src/controllers/` and must be registered in `assets-src/boot.js`.
**Assets have no host-side pipeline.** Committed, digest-named artifacts live in `app/assets/flightdeck/` with `manifest.json`; sources in `assets-src/` (Tailwind v4 CSS, vendored Turbo/Stimulus, Stimulus controllers concatenated by `rake assets:build` — no node, no importmap). `assets-src/input.css` uses `@import "tailwindcss" source(none)` plus explicit `@source` directives: this makes the build byte-deterministic across machines (automatic source detection once scanned CI's `vendor/bundle` and changed the output). Do not remove those directives. New Stimulus controllers go in `assets-src/controllers/` and must be registered in `assets-src/boot.js`. Fonts are vendored woff2 under `assets-src/fonts/` (`fonts.json` is the index; `fetch_fonts.rb` re-downloads them and is the only thing that needs network); `rake assets:build` copies each into the manifest under a digest name and prepends `@font-face` rules with **relative** `url()`s, which is what keeps them resolvable under any mount path. Never inline them as data: URIs again — the point is that a browser downloads only the face it renders in.

**Job state is derived, never stored**: which execution table holds a job defines its state (failed/claimed/blocked/scheduled/ready; `finished_at` on the job row for finished). `Flightdeck::JobsQuery` drives each list from that state's own table, uses keyset pagination (`id < before_id`), caps counts (`count_cap`, rendered "500,000+"), and **never selects `arguments` or `error` in list queries** — previews are fetched per-page with SQL `SUBSTR` and parsed leniently (`ArgumentsPreview`, `ErrorSummary` handle truncated JSON). Tests assert this via SQL capture. Arguments are never deserialized through ActiveJob (job classes may not be loadable).

Expand All @@ -47,6 +47,8 @@ Maintain `CHANGELOG.md` for user-visible changes — brief and simple: one line

**Theme tokens** (`assets-src/input.css`): the light and dark palettes are each written once; four selector blocks (OS default, OS-dark guard, `data-theme` stamps) only map tokens to palette entries. `test/theme_tokens_test.rb` enforces the blocks stay in step — add any new token to the palette maps, not to an individual block.

**UI font** (`Flightdeck::UiFonts`): one slug per offered face, stamped as `data-font` on `<html>` from `config.ui_font` and overridden per user by the `font` Stimulus controller. Only the `[data-font]` blocks in `input.css` name a family; every other rule reads `--font-ui`. Adding a face means all four of: vendored woff2, a `[data-font]` block, a `UiFonts::LABELS` entry, and `rake assets:build` — `test/ui_fonts_test.rb` fails if any is missing. The mono face is deliberately not configurable.

## Testing conventions

- The dummy host app is `test/dummy` (schema in `db/schema.rb`, loaded by `test_helper.rb` — there are no working `db:*` rake tasks). It runs API-only when `FLIGHTDECK_TEST_API_ONLY` is set.
Expand Down
116 changes: 116 additions & 0 deletions FONT-LICENSES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Fonts bundled with Flightdeck

Flightdeck ships the webfonts it renders in, so the dashboard never calls out
to a third-party CDN. The woff2 files under `app/assets/flightdeck/` are
unmodified subsets/instances of the upstream releases below; `assets-src/fonts/fonts.json`
records the exact source URL each one came from.

| Family | Foundry | Licence | Source |
| --- | --- | --- | --- |
| Barlow | Jeremy Tribby | SIL Open Font License 1.1 | https://fonts.google.com/specimen/Barlow |
| IBM Plex Mono | IBM / Mike Abbink, Bold Monday | SIL Open Font License 1.1 | https://fonts.google.com/specimen/IBM+Plex+Mono |
| Inter | Rasmus Andersson | SIL Open Font License 1.1 | https://fonts.google.com/specimen/Inter |
| Manrope | Mikhail Sharanda | SIL Open Font License 1.1 | https://fonts.google.com/specimen/Manrope |
| Public Sans | USWDS / Dan O Sullivan | SIL Open Font License 1.1 | https://fonts.google.com/specimen/Public+Sans |
| General Sans | Indian Type Foundry | ITF Free Font Licence | https://www.fontshare.com/fonts/general-sans |

## SIL Open Font License 1.1

Applies to Barlow, IBM Plex Mono, Inter, Manrope and Public Sans. Each retains
its own upstream copyright notice, linked above.

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org


-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

## ITF Free Font Licence

Applies to General Sans. See https://www.fontshare.com/licenses/itf-ffl
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ the engine answers 401 behind your constraint.
| `chart_cache_ttl` | `30.seconds` | How long chart series are cached. |
| `display_timezone` | `"UTC"` | Timezone for displayed timestamps. Queries stay in UTC. |
| `backtrace_lines` | `50` | Backtrace frames shown on job detail. |
| `ui_font` | `"public-sans"` | Default interface typeface. Each user can pick another one in the sidebar. |

## Interface font

The bottom of the sidebar has a font picker, and the choice is remembered per
browser. To set the default everyone starts on:

```ruby
Flightdeck.configure do |config|
config.ui_font = "inter"
end
```

Available: `public-sans` (default), `barlow`, `general-sans`, `inter`,
`manrope`, and `system` (whatever your OS uses for its own UI, no webfont
downloaded). Anything else raises at boot rather than falling back silently.

All faces ship with the gem — nothing is fetched from Google Fonts or any other
CDN at runtime, so the dashboard works on an isolated network and leaks no
requests to third parties. They are served as ordinary digest-named assets
rather than inlined, so a browser only downloads the one face it is rendering
in. The monospace face (IBM Plex Mono) is fixed: the job tables depend on its
tabular figures.

## Chart history and job retention

Expand Down
62 changes: 51 additions & 11 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,15 @@ end
task default: %i[test]

namespace :assets do
desc "Build flightdeck.css + flightdeck.js and the digested manifest"
desc "Build flightdeck.css + flightdeck.js + the fonts, and the digested manifest"
task :build do
FileUtils.mkdir_p(OUT)

css = build_css
# Fonts first: the stylesheet's @font-face rules point at their digested
# filenames, so those have to exist before the CSS is hashed.
fonts = build_fonts

css = build_css(fonts)
js = build_js

manifest = {}
Expand All @@ -73,6 +77,8 @@ namespace :assets do
}
end

manifest.merge!(fonts)

File.write(MANIFEST, JSON.pretty_generate(manifest) + "\n")
prune_stale(manifest)

Expand Down Expand Up @@ -108,16 +114,23 @@ namespace :assets do
problems << "#{logical}: not present in the manifest" unless manifest.key?(logical)
end

# A face vendored but never built would silently fall back to system-ui in
# the browser, which is easy to miss by eye.
vendored = JSON.parse(File.read(File.join(SRC, "fonts", "fonts.json"))).keys
(vendored - manifest.keys).each do |logical|
problems << "#{logical}: vendored in assets-src/fonts but not in the manifest"
end

abort "Stale assets:\n " + problems.join("\n ") if problems.any?

puts "assets are fresh (#{manifest.keys.join(", ")})"
puts "assets are fresh (#{manifest.keys.size} entries: #{LOGICAL.keys.join(", ")} + #{vendored.size} fonts)"
end
end

desc "Alias for assets:build"
task assets: "assets:build"

def build_css
def build_css(fonts)
require "tailwindcss/ruby"

input = File.join(SRC, "input.css")
Expand All @@ -131,19 +144,46 @@ def build_css
css = File.read(tmp)
FileUtils.rm_f(tmp)

font_face_css + css
font_face_css(fonts) + css
end

def font_face_css
# Copies every vendored woff2 into the output directory under a digested name
# and returns its manifest fragment. Fonts are served as ordinary assets rather
# than inlined as data: URIs: a browser then downloads only the family the user
# actually reads the dashboard in, instead of all of them on first paint.
def build_fonts
index_path = File.join(SRC, "fonts", "fonts.json")
return {} unless File.file?(index_path)

JSON.parse(File.read(index_path)).each_with_object({}) do |(logical, _meta), manifest|
content = File.binread(File.join(SRC, "fonts", logical))
digest = Digest::SHA256.hexdigest(content)
short = digest[0, 12]
file = "flightdeck-#{File.basename(logical, ".woff2")}-#{short}.woff2"

File.binwrite(File.join(OUT, file), content)
manifest[logical] = {
"file" => file,
"digest" => short,
"sha256" => digest,
"content_type" => "font/woff2",
"size" => content.bytesize
}
end
end

# The url() is relative, so it resolves against the stylesheet's own URL and
# stays correct wherever the engine is mounted.
def font_face_css(fonts)
index_path = File.join(SRC, "fonts", "fonts.json")
return "" unless File.file?(index_path)

JSON.parse(File.read(index_path)).map do |file, meta|
bytes = File.binread(File.join(SRC, "fonts", file))
data = Base64.strict_encode64(bytes)
JSON.parse(File.read(index_path)).map do |logical, meta|
file = fonts.fetch(logical).fetch("file")

"@font-face{font-family:'#{meta["family"]}';font-style:normal;" \
"font-weight:#{meta["weight"]};font-display:swap;" \
"src:url(data:font/woff2;base64,#{data}) format('woff2')}"
"src:url(#{file}) format('woff2')}"
end.join + "\n"
end

Expand Down Expand Up @@ -173,7 +213,7 @@ end

def prune_stale(manifest)
keep = manifest.values.map { |entry| entry["file"] } + [ "manifest.json" ]
Dir[File.join(OUT, "flightdeck-*.{css,js}")].each do |path|
Dir[File.join(OUT, "flightdeck-*.{css,js,woff2}")].each do |path|
FileUtils.rm_f(path) unless keep.include?(File.basename(path))
end
end
3 changes: 0 additions & 3 deletions app/assets/flightdeck/flightdeck-3967b9e6c506.css

This file was deleted.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions app/assets/flightdeck/flightdeck-6f786a79a591.css

Large diffs are not rendered by default.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading