util/docker/coreboot.org-status: Rewrite parser

The current tool is a shell script that mixes data collection and HTML
generation and is generally a pain to work with. It takes 15 minutes to
run.

The new tool is written in go, collects all data first, then generates
the output HTML from the data and a single template, and finishes in
10 seconds.

The goal in this version is to produce output as similar as possible to
the output of the shell script. Some difference will remain because the
shell script returns some trash data whose reproduction would require
more effort than is worth.

Change-Id: I4fab86d24088e4f9eff434c21ce9caa077f3f9e2
Signed-off-by: Patrick Georgi <pgeorgi@google.com>
Reviewed-on: https://review.coreboot.org/c/coreboot/+/59958
Tested-by: build bot (Jenkins) <no-reply@coreboot.org>
Reviewed-by: Maxim Polyakov <max.senia.poliak@gmail.com>
This commit is contained in:
Patrick Georgi 2021-12-07 17:47:45 +01:00 committed by Patrick Georgi
parent 5318d9c9d1
commit 3d0303a57c
7 changed files with 1158 additions and 0 deletions

View File

@ -0,0 +1,601 @@
package main
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
func readBoardInfo(dir NamedFS) map[string]string {
result := make(map[string]string)
c, err := fs.ReadFile(dir.FS, filepath.Join(dir.Name, "board_info.txt"))
if err != nil {
return result
}
ls := strings.Split(string(c), "\n")
for _, l := range ls {
spl := strings.SplitN(l, ":", 2)
if len(spl) != 2 {
// This shouldn't ever happen, but let's try to
// extract as much information from erroneous
// board_info files (if they exist) as possible.
continue
}
result[strings.TrimSpace(spl[0])] = strings.TrimSpace(spl[1])
}
return result
}
func fetchBoards(dirs chan<- NamedFS) {
defer close(dirs)
ds, err := fs.Glob(cbdirFS, filepath.Join("src", "mainboard", "*", "*"))
if err != nil {
fmt.Fprintf(os.Stderr, "Could not find mainboard directories: %v\n", err)
return
}
for _, d := range ds {
if _, err := fs.ReadDir(cbdirFS, d); err != nil {
continue
}
dirs <- NamedFS{
FS: cbdirFS,
Name: d,
}
}
}
var niceVendors = make(map[string]string)
func getNiceVendor(dir string, vendor string) (string, error) {
if _, exists := niceVendors[vendor]; !exists {
c, err := fs.ReadFile(cbdirFS, filepath.Join(dir, "..", "Kconfig.name"))
if err != nil {
return "", err
}
re, err := regexp.Compile("(?i)config VENDOR_" + vendor)
if err != nil {
return "", err
}
ls := strings.Split(string(c), "\n")
next := false
for _, l := range ls {
if next {
niceVendors[vendor] = strings.Split(l, "\"")[1]
break
}
if re.Match([]byte(l)) {
next = true
}
}
}
return niceVendors[vendor], nil
}
func readKconfig(dir NamedFS) (string, string, string, string, string, error) {
var north, south, superio, cpu, partnum string
c, err := fs.ReadFile(dir.FS, filepath.Join(dir.Name, "Kconfig"))
if err != nil {
return north, south, superio, cpu, partnum, err
}
ls := strings.Split(string(c), "\n")
partoffset := 0
for _, l := range ls {
l = strings.TrimSpace(l)
if len(l) < 7 {
continue
}
// TODO: handling of MAINBOARD_PART_NUMBER is rather broken
// and fragile. Doesn't help that many boards use different
// part numbers for different models and this code can't
// figure it out.
if strings.Contains(strings.ToLower(l), "config mainboard_part_number") {
partoffset = 2
continue
}
if partoffset > 0 {
partoffset--
if strings.Contains(l, "default") {
partnum = strings.Split(l, "\"")[1]
continue
}
}
if l[0:7] != "select " {
continue
}
l = l[7:]
if len(l) > 12 && l[0:12] == "NORTHBRIDGE_" {
north = l[12:]
continue
}
if len(l) > 12 && l[0:12] == "SOUTHBRIDGE_" {
if strings.Contains(l, "SKIP_") ||
strings.Contains(l, "DISABLE_") {
continue
}
south = l[12:]
continue
}
if len(l) > 8 && l[0:8] == "SUPERIO_" {
superio = l[8:]
continue
}
if len(l) > 4 && (l[0:4] == "CPU_" || l[0:4] == "SOC_") {
if strings.Contains(l, "AMD_AGESA_FAMILY") ||
strings.Contains(l, "AMD_COMMON_") ||
strings.Contains(l, "INTEL_COMMON_") ||
strings.Contains(l, "INTEL_DISABLE_") ||
strings.Contains(l, "INTEL_CSE_") ||
strings.Contains(l, "CPU_MICROCODE_CBFS_NONE") {
continue
}
cpu = l[4:]
}
}
return north, south, superio, cpu, partnum, nil
}
type reReplace struct {
pattern *regexp.Regexp
replace string
}
func prettify(input string, rules *[]reReplace) string {
for _, rule := range *rules {
input = rule.pattern.ReplaceAllString(input, rule.replace)
}
return input
}
var northbridgeRules = []reReplace{
{
pattern: regexp.MustCompile("AMD_AGESA_FAMILY([0-9a-fA-F]*)(.*)"),
replace: "AMD Family ${1}h${2} (AGESA)",
},
{
pattern: regexp.MustCompile("AMD_PI_(.*)"),
replace: "AMD ${1} (PI)",
},
{
pattern: regexp.MustCompile("INTEL_FSP_(.*)"),
replace: "Intel® ${1} (FSP)",
},
{
pattern: regexp.MustCompile("AMD_FAMILY([0-9a-fA-F]*)"),
replace: "AMD Family ${1}h,",
},
{
pattern: regexp.MustCompile("AMD_AMDFAM([0-9a-fA-F]*)"),
replace: "AMD Family ${1}h",
},
{
pattern: regexp.MustCompile("_"),
replace: " ",
},
{
pattern: regexp.MustCompile("INTEL"),
replace: "Intel®",
},
}
func prettifyNorthbridge(northbridge string) string {
return prettify(northbridge, &northbridgeRules)
}
var southbridgeRules = []reReplace{
{
pattern: regexp.MustCompile("_"),
replace: " ",
},
{
pattern: regexp.MustCompile("INTEL"),
replace: "Intel®",
},
}
func prettifySouthbridge(southbridge string) string {
return prettify(southbridge, &southbridgeRules)
}
var superIORules = []reReplace{
{
pattern: regexp.MustCompile("_"),
replace: " ",
},
{
pattern: regexp.MustCompile("WINBOND"),
replace: "Winbond™,",
},
{
pattern: regexp.MustCompile("ITE"),
replace: "ITE™",
},
{
pattern: regexp.MustCompile("SMSC"),
replace: "SMSC®",
},
{
pattern: regexp.MustCompile("NUVOTON"),
replace: "Nuvoton ",
},
}
func prettifySuperIO(superio string) string {
return prettify(superio, &superIORules)
}
type cpuMapping struct {
cpu string
socket string
}
var cpuMappings = map[string]cpuMapping{
"ALLWINNER_A10": {
cpu: "Allwinner A10",
socket: "?",
},
"AMD_GEODE_LX": {
cpu: "AMD Geode™ LX",
socket: "—",
},
"AMD_SOCKET_754": {
cpu: "AMD Sempron™ / Athlon™ 64 / Turion™ 64",
socket: "Socket 754",
},
"AMD_SOCKET_ASB2": {
cpu: "AMD Turion™ II Neo/Athlon™ II Neo",
socket: "ASB2 (BGA812)",
},
"AMD_SOCKET_S1G1": {
cpu: "AMD Turion™ / X2 Sempron™",
socket: "Socket S1G1",
},
"AMD_SOCKET_G34": {
cpu: "AMD Opteron™ Magny-Cours/Interlagos",
socket: "Socket G34",
},
"AMD_SOCKET_G34_NON_AGESA": {
cpu: "AMD Opteron™ Magny-Cours/Interlagos",
socket: "Socket G34",
},
"AMD_SOCKET_C32": {
cpu: "AMD Opteron™ Magny-Cours/Interlagos",
socket: "Socket C32",
},
"AMD_SOCKET_C32_NON_AGESA": {
cpu: "AMD Opteron™ Magny-Cours/Interlagos",
socket: "Socket C32",
},
"AMD_SOCKET_AM2": {
cpu: "?",
socket: "Socket AM2",
},
"AMD_SOCKET_AM3": {
cpu: "AMD Athlon™ 64 / FX / X2",
socket: "Socket AM3",
},
"AMD_SOCKET_AM2R2": {
cpu: "AMD Athlon™ 64 / X2 / FX, Sempron™",
socket: "Socket AM2+",
},
"AMD_SOCKET_F": {
cpu: "AMD Opteron™",
socket: "Socket F",
},
"AMD_SOCKET_F_1207": {
cpu: "AMD Opteron™",
socket: "Socket F 1207",
},
"AMD_SOCKET_940": {
cpu: "AMD Opteron™",
socket: "Socket 940",
},
"AMD_SOCKET_939": {
cpu: "AMD Athlon™ 64 / FX / X2",
socket: "Socket 939",
},
"AMD_SC520": {
cpu: "AMD Élan™SC520",
socket: "—",
},
"AMD_STONEYRIDGE_FP4": {
cpu: "AMD Stoney Ridge",
socket: "FP4 BGA",
},
"ARMLTD_CORTEX_A9": {
cpu: "ARM Cortex A9",
socket: "?",
},
"DMP_VORTEX86EX": {
cpu: "DMP VORTEX86EX",
socket: "?",
},
"MEDIATEK_MT8173": {
cpu: "MediaTek MT8173",
socket: "—",
},
"NVIDIA_TEGRA124": {
cpu: "NVIDIA Tegra 124",
socket: "—",
},
"NVIDIA_TEGRA210": {
cpu: "NVIDIA Tegra 210",
socket: "—",
},
"SAMSUNG_EXYNOS5420": {
cpu: "Samsung Exynos 5420",
socket: "?",
},
"SAMSUNG_EXYNOS5250": {
cpu: "Samsung Exynos 5250",
socket: "?",
},
"TI_AM335X": {
cpu: "TI AM335X",
socket: "?",
},
"INTEL_APOLLOLAKE": {
cpu: "Intel® Apollo Lake",
socket: "—",
},
"INTEL_BAYTRAIL": {
cpu: "Intel® Bay Trail",
socket: "—",
},
"INTEL_BRASWELL": {
cpu: "Intel® Braswell",
socket: "—",
},
"INTEL_BROADWELL": {
cpu: "Intel® Broadwell",
socket: "—",
},
"INTEL_DENVERTON_NS": {
cpu: "Intel® Denverton-NS",
socket: "—",
},
"INTEL_FSP_BROADWELL_DE": {
cpu: "Intel® Broadwell-DE",
socket: "—",
},
"INTEL_GLK": {
cpu: "Intel® Gemini Lake",
socket: "—",
},
"INTEL_GEMINILAKE": {
cpu: "Intel® Gemini Lake",
socket: "—",
},
"INTEL_ICELAKE": {
cpu: "Intel® Ice Lake",
socket: "—",
},
"INTEL_KABYLAKE": {
cpu: "Intel® Kaby Lake",
socket: "—",
},
"INTEL_SANDYBRIDGE": {
cpu: "Intel® Sandy Bridge",
socket: "—",
},
"INTEL_SKYLAKE": {
cpu: "Intel® Skylake",
socket: "—",
},
"INTEL_SLOT_1": {
cpu: "Intel® Pentium® II/III, Celeron®",
socket: "Slot 1",
},
"INTEL_SOCKET_MPGA604": {
cpu: "Intel® Xeon®",
socket: "Socket 604",
},
"INTEL_SOCKET_M": {
cpu: "Intel® Core™ 2 Duo Mobile, Core™ Duo/Solo, Celeron® M",
socket: "Socket M (mPGA478MT)",
},
"INTEL_SOCKET_LGA771": {
cpu: "Intel Xeon™ 5000 series",
socket: "Socket LGA771",
},
"INTEL_SOCKET_LGA775": {
cpu: "Intel® Core 2, Pentium 4/D",
socket: "Socket LGA775",
},
"INTEL_SOCKET_PGA370": {
cpu: "Intel® Pentium® III-800, Celeron®",
socket: "Socket PGA370",
},
"INTEL_SOCKET_MPGA479M": {
cpu: "Intel® Mobile Celeron",
socket: "Socket 479",
},
"INTEL_HASWELL": {
cpu: "Intel® 4th Gen (Haswell) Core i3/i5/i7",
socket: "?",
},
"INTEL_FSP_RANGELEY": {
cpu: "Intel® Atom Rangeley (FSP)",
socket: "?",
},
"INTEL_SOCKET_441": {
cpu: "Intel® Atom™ 230",
socket: "Socket 441",
},
"INTEL_SOCKET_FC_PGA370": {
cpu: "Intel® Pentium® III, Celeron®",
socket: "Socket PGA370",
},
"INTEL_EP80579": {
cpu: "Intel® EP80579",
socket: "Intel® EP80579",
},
"INTEL_SOCKET_MFCBGA479": {
cpu: "Intel® Mobile Celeron",
socket: "Socket 479",
},
"INTEL_WHISKEYLAKE": {
cpu: "Intel® Whiskey Lake",
socket: "—",
},
"QC_IPQ806X": {
cpu: "Qualcomm IPQ806x",
socket: "—",
},
"QUALCOMM_QCS405": {
cpu: "Qualcomm QCS405",
socket: "—",
},
"ROCKCHIP_RK3288": {
cpu: "Rockchip RK3288",
socket: "—",
},
"ROCKCHIP_RK3399": {
cpu: "Rockchip RK3399",
socket: "—",
},
"VIA_C3": {
cpu: "VIA C3™",
socket: "?",
},
"VIA_C7": {
cpu: "VIA C7™",
socket: "?",
},
"VIA_NANO": {
cpu: "VIA NANO™",
socket: "?",
},
"QEMU_X86": {
cpu: "QEMU x86",
socket: "—",
},
}
func prettifyCPU(cpu, north string, northNice string) (string, string) {
if match, ok := cpuMappings[cpu]; ok {
return match.cpu, match.socket
}
if cpu == "" {
if match, ok := cpuMappings[north]; ok {
return match.cpu, match.socket
}
if north == "INTEL_IRONLAKE" {
return "Intel® 1st Gen (Westmere) Core i3/i5/i7", "?"
}
if north == "RDC_R8610" {
return "RDC R8610", "—"
}
if (len(north) > 10 && north[0:10] == "AMD_AGESA_") || (len(north) > 7 && north[0:7] == "AMD_PI_") {
return northNice, "?"
}
return north, north
}
if cpu == "INTEL_SOCKET_BGA956" {
if north == "INTEL_GM45" {
return "Intel® Core 2 Duo (Penryn)", "Socket P"
}
return "Intel® Pentium® M", "BGA956"
}
if cpu == "INTEL_SOCKET_RPGA989" || cpu == "INTEL_SOCKET_LGA1155" || cpu == "INTEL_SOCKET_RPGA988B" {
socket := "Socket " + cpu[13:]
if north == "INTEL_HASWELL" {
return "Intel® 4th Gen (Haswell) Core i3/i5/i7", socket
}
if north == "INTEL_IVYBRIDGE" || north == "INTEL_FSP_IVYBRIDGE" {
return "Intel® 3rd Gen (Ivybridge) Core i3/i5/i7", socket
}
if north == "INTEL_SANDYBRIDGE" {
return "Intel® 2nd Gen (Sandybridge) Core i3/i5/i7", socket
}
return north, socket
}
return cpu, cpu
}
func collectBoards(dirs <-chan NamedFS) {
for dir := range dirs {
path := strings.Split(dir.Name, string(filepath.Separator))
vendor, board := path[2], path[3]
vendorNice, err := getNiceVendor(dir.Name, vendor)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not find nice vendor name for %s: %v\n", dir.Name, err)
continue
}
bi := readBoardInfo(dir)
cat := Category(bi["Category"])
if _, ok := data.CategoryNiceNames[cat]; !ok {
cat = "unclass"
}
if bi["Vendor cooperation score"] == "" {
bi["Vendor cooperation score"] = "—"
}
north, south, superio, cpu, partnum, err := readKconfig(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "'%s' is not a mainboard directory: %v\n", dir.Name, err)
// Continue with the path because that's what the
// shell script did. We might want to change semantics
// later.
}
northbridgeNice := prettifyNorthbridge(north)
southbridgeNice := prettifySouthbridge(south)
superIONice := prettifySuperIO(superio)
cpuNice, socketNice := prettifyCPU(cpu, north, northbridgeNice)
boardNice := bi["Board name"]
if boardNice == "" {
boardNice = partnum
}
if boardNice == "" {
boardNice = strings.ReplaceAll(boardNice, "_", " ")
boardNice = strings.ToUpper(boardNice)
}
b := Board{
Vendor: vendor,
Vendor2nd: bi["Vendor name"],
VendorNice: vendorNice,
VendorBoard: vendor + "/" + board,
Board: board,
BoardNice: boardNice,
BoardURL: bi["Board URL"],
NorthbridgeNice: northbridgeNice,
SouthbridgeNice: southbridgeNice,
SuperIONice: superIONice,
CPUNice: cpuNice,
SocketNice: socketNice,
ROMPackage: bi["ROM package"],
ROMProtocol: bi["ROM protocol"],
ROMSocketed: bi["ROM socketed"],
FlashromSupport: bi["Flashrom support"],
VendorCooperationScore: bi["Vendor cooperation score"],
VendorCooperationPage: bi["Vendor cooperation page"],
}
if b.ROMPackage == "" {
b.ROMPackage = "?"
}
if b.ROMProtocol == "" {
b.ROMProtocol = "?"
}
if data.BoardsByCategory[cat] == nil {
data.BoardsByCategory[cat] = []Board{}
}
data.BoardsByCategory[cat] = append(data.BoardsByCategory[cat], b)
}
for ci := range data.BoardsByCategory {
cat := data.BoardsByCategory[ci]
sort.Slice(data.BoardsByCategory[ci], func(i, j int) bool {
if cat[i].Vendor == cat[j].Vendor {
return cat[i].Board < cat[j].Board
}
return cat[i].Vendor < cat[j].Vendor
})
}
}

View File

@ -0,0 +1,23 @@
module review.coreboot.org/coreboot.git/util/docker/coreboot.org-status/board-status.html
go 1.17
require (
github.com/Microsoft/go-winio v0.4.16 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect
github.com/acomagu/bufpipe v1.0.3 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.3.1 // indirect
github.com/go-git/go-git/v5 v5.4.2 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/xanzy/ssh-agent v0.3.0 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/net v0.0.0-20210326060303-6b1517762897 // indirect
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

View File

@ -0,0 +1,84 @@
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs=
golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79 h1:RX8C8PRZc2hTIod4ds8ij+/4RQX3AqhYj3uOHmyaz4E=
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,165 @@
package main
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// Color returns a HTML color code between green and yellow based on the
// number of days that passed since ds.
func (ds DateString) Color() string {
date, _ := time.Parse("2006-01-02T15:04:05Z", string(ds))
days := int(time.Since(date).Hours() / 24)
if days > 255 {
days = 255
}
return fmt.Sprintf("#%02xff00", days)
}
func fetchLogs(dirs chan<- NamedFS) {
err := fs.WalkDir(bsdirFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if path[0] == '.' {
return nil
}
if d.IsDir() && len(strings.Split(path, string(filepath.Separator))) == 4 {
dirs <- NamedFS{
FS: bsdirFS,
Name: path,
}
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Reading logs failed: %v\n", err)
}
close(dirs)
}
func collectLogs(dirs <-chan NamedFS) {
data.Logs = make(map[Timeframe][]Log)
data.VendorBoardDate = make(map[string]DateString)
data.VendorBoardReferenced = make(map[string]bool)
timeframes := make(map[Timeframe]bool)
gitcache := make(map[string]string)
for dir := range dirs {
upstream := ""
revB, err := fs.ReadFile(dir.FS, filepath.Join(dir.Name, "revision.txt"))
if err != nil {
continue
}
rev := string(revB)
skipDir := false
for _, line := range strings.Split(rev, "\n") {
item := strings.SplitN(line, ":", 2)
if len(item) != 2 {
// This is an error, but let's try to extract
// as much value out of revision.txt files as
// possible, even if some lines are erroneous.
continue
}
if item[0] == "Upstream revision" {
upstream = strings.TrimSpace(item[1])
// tried using go-git, but its resolver
// couldn't expand short hashes despite the
// docs claiming that it can.
if val, ok := gitcache[upstream]; ok {
upstream = val
} else {
res, err := exec.Command("/usr/bin/git", "-C", cbdir, "log", "-n1", "--format=%H", upstream).Output()
if err != nil {
fmt.Fprintf(os.Stderr, "revision %s not found \n", upstream)
skipDir = true
break
}
gitcache[upstream] = strings.TrimSpace(string(res))
upstream = gitcache[upstream]
}
}
}
if skipDir {
continue
}
rawFiles, err := fs.Glob(dir.FS, filepath.Join(dir.Name, "*"))
if err != nil {
fmt.Fprintf(os.Stderr, "Could not fetch log data, skipping: %v\n", err)
continue
}
pieces := strings.Split(dir.Name, string(filepath.Separator))
if len(pieces) < 4 {
fmt.Fprintf(os.Stderr, "log directory %s is malformed, skipping\n", dir.Name)
continue
}
vendorBoard := strings.Join(pieces[:2], "/")
// TODO: these need to become "second to last" and "last" item
// but only after compatibility to the current system has been ensured.
dateTimePath := pieces[3]
dateTime, err := time.Parse(time.RFC3339, strings.ReplaceAll(dateTimePath, "_", ":"))
if err != nil {
fmt.Fprintf(os.Stderr, "Could not parse timestamp from %s: %v\n", dir.Name, err)
continue
}
dateTimeNormal := dateTime.UTC().Format("2006-01-02T15:04:05Z")
dateTimeHuman := dateTime.UTC().Format(time.UnixDate)
tfYear, tfWeek := dateTime.ISOWeek()
timeframe := Timeframe(fmt.Sprintf("%dW%02d", tfYear, tfWeek))
if !timeframes[timeframe] {
timeframes[timeframe] = true
data.Timeframes = append(data.Timeframes, timeframe)
data.Logs[timeframe] = []Log{}
}
files := []Path{}
l := len(dir.Name) + 1
for _, file := range rawFiles {
if file[l:] == "revision.txt" {
continue
}
files = append(files, Path{
Path: dir.Name + "/",
Basename: file[l:],
})
}
data.Logs[timeframe] = append(data.Logs[timeframe], Log{
VendorBoard: vendorBoard,
Time: dateTimeNormal,
TimeReadable: dateTimeHuman,
Upstream: upstream,
Files: files,
})
}
sort.Slice(data.Timeframes, func(i, j int) bool {
// reverse sort
return data.Timeframes[i] > data.Timeframes[j]
})
for bi := range data.Logs {
bucket := data.Logs[bi]
sort.Slice(data.Logs[bi], func(i, j int) bool {
if bucket[i].Time == bucket[j].Time {
return (bucket[i].VendorBoard > bucket[j].VendorBoard)
}
return (bucket[i].Time > bucket[j].Time)
})
}
for _, ts := range data.Timeframes {
for li, l := range data.Logs[ts] {
if _, match := data.VendorBoardDate[l.VendorBoard]; match {
continue
}
data.VendorBoardDate[l.VendorBoard] = DateString(l.Time)
data.Logs[ts][li].Reference = true
}
}
}

View File

@ -0,0 +1,92 @@
package main
import (
"embed"
"errors"
"flag"
"fmt"
"html/template"
"io/fs"
"os"
"path/filepath"
)
//go:embed templates
var templates embed.FS
var data = TemplateData{
Categories: []Category{
"laptop",
"server",
"desktop",
"half",
"mini",
"settop",
"eval",
"sbc",
"emulation",
"misc",
"unclass",
},
CategoryNiceNames: map[Category]string{
"desktop": "Desktops / Workstations",
"server": "Servers",
"laptop": "Laptops",
"half": "Embedded / PC/104 / Half-size boards",
"mini": "Mini-ITX / Micro-ITX / Nano-ITX",
"settop": "Set-top-boxes / Thin clients",
"eval": "Devel/Eval Boards",
"sbc": "Single-Board computer",
"emulation": "Emulation",
"misc": "Miscellaneous",
"unclass": "Unclassified",
},
BoardsByCategory: map[Category][]Board{},
}
var (
cbdirFS fs.FS
cbdir string
bsdirFS fs.FS
)
func main() {
var cbDir, bsDir string
flag.StringVar(&cbDir, "coreboot-dir", filepath.Join("..", "coreboot.git"), "coreboot.git checkout")
flag.StringVar(&bsDir, "board-status-dir", filepath.Join("..", "board-status.git"), "board-status.git checkout")
flag.Parse()
tpls, err := template.ParseFS(templates, filepath.Join("templates", "*"))
if err != nil {
fmt.Fprintf(os.Stderr, "Parsing templates failed: %v\n", err)
os.Exit(1)
}
if _, err := os.Stat(cbDir); errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(os.Stderr, "coreboot root %s does not exist\n", cbDir)
os.Exit(1)
}
if _, err := os.Stat(bsDir); errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(os.Stderr, "board-status dir %s does not exist\n", bsDir)
os.Exit(1)
}
cbdirFS = os.DirFS(cbDir)
cbdir = cbDir
bsdirFS = os.DirFS(bsDir)
dirs := make(chan NamedFS)
go fetchLogs(dirs)
collectLogs(dirs)
dirs = make(chan NamedFS)
go fetchBoards(dirs)
collectBoards(dirs)
err = tpls.ExecuteTemplate(os.Stdout, "board-status.html", data)
if err != nil {
fmt.Fprintf(os.Stderr, "Executing template failed: %v\n", err)
os.Exit(1)
}
}

View File

@ -0,0 +1,133 @@
{{$data := . -}}
{{- define "colorcode" -}}
{{- if eq . "n" }}<td style="background:red">N</td>{{else
if eq . "y" }}<td style="background:lime">Y</td>{{else
if eq . "" }}<td>?</td>{{else
}}<td>{{.}}</td>{{end -}}
{{end -}}
<html>
<head>
<meta charset="utf-8">
<title>status report for coreboot boards</title>
</head>
<body>
<h1>Mainboards supported by coreboot</h1>
<p>This page shows two representations of the same data:</p>
<p>First a list of all mainboards supported by coreboot (current within
one hour) ordered by category. For each mainboard the table shows the
latest user-contributed report of a successful boot on the device.</p>
<p>After that, the page provides a time-ordered list of these contributed
reports, with the newest report first.</p>
<p>Boards without such reports may boot or there may be some maintenance
required. The reports contain the coreboot configuration and precise commit
id, so it is possible to reproduce the build.</p>
<p>We encourage developers and users to contribute reports so we know which
devices are well-tested. We have
<a href='https://review.coreboot.org/plugins/gitiles/coreboot/+/refs/heads/master/util/board_status/'>a tool in the coreboot repository</a>
to make contributing easy. The data resides in the
<a href='https://review.coreboot.org/plugins/gitiles/board-status/'>board status repository</a>.
Contributing requires an account on review.coreboot.org. After
logging into the site with your preferred OpenID or GitHub/Google
credentials, you can get a user name and password for git pushes on <a
href="https://review.coreboot.org/settings/#HTTPCredentials">gerrit's
settings screen</a>.</p>
<p>Sometimes the same board is sold under different names, we've tried to
list all known names but some names might be missing.</p>
<p>If the board is not found in the coreboot's source code, there might
be some form of support that is not ready yet for inclusion in coreboot,
usually people willing to send their patches to coreboot goes through
<a href='https://review.coreboot.org'>gerrit</a>, so looking there could find some
code for boards that are not yet merged.</p>
<h1>Vendor trees</h1>
<p>Some vendors have their own coreboot trees/fork, for instance:
<ul>
<li><a href='https://chromium.googlesource.com/chromiumos/third_party/coreboot/'>chrome/chromium's tree</a>
</ul>
</p>
<h1>Motherboards supported in coreboot</h1>
<table border="0" style="font-size: smaller">
<tr bgcolor="#6699ff">
<td>Vendor</td>
<td>Mainboard</td>
<td>Latest known good</td>
<td>Northbridge</td>
<td>Southbridge</td>
<td>Super&nbsp;I/O</td>
<td>CPU</td>
<td>Socket</td>
<td><span title="ROM chip package">ROM&nbsp;<sup>1</sup></span></td>
<td><span title="ROM chip protocol">P&nbsp;<sup>2</sup></span></td>
<td><span title="ROM chip socketed?">S&nbsp;<sup>3</sup></span></td>
<td><span title="Board supported by flashrom?">F&nbsp;<sup>4</sup></span></td>
<td><span title="Vendor Cooperation Score">VCS<sup>5</sup></span></td>
</tr>
{{range $category := .Categories -}}
<tr bgcolor="#6699ff">
<td colspan="13"><h4>{{index $data.CategoryNiceNames $category}}</h4></td>
</tr>
{{$color := "#eeeeee" -}}
{{$oldVendor := "" -}}
{{range $b := index $data.BoardsByCategory $category -}}
{{if ne $oldVendor $b.VendorNice}}{{$oldVendor = $b.VendorNice -}}
{{if eq $color "#dddddd"}}{{$color = "#eeeeee"}}{{else}}{{$color = "#dddddd"}}{{end -}}
{{end -}}
<tr bgcolor="{{$color}}">
<td>{{if $b.BoardURL}}<a href='{{$b.BoardURL}}'>{{$b.VendorNice}}</a>{{else}}{{$b.VendorNice}}{{end}}
{{if $b.Vendor2nd}} ({{$b.Vendor2nd}})
{{end -}}
</td><td><a href='https://www.coreboot.org/Board:{{$b.Vendor}}/{{$b.Board}}'>{{$b.BoardNice}}</a></td>
{{if eq "" (index $data.VendorBoardDate $b.VendorBoard) -}}
<td style="background:red">Unknown</td>
{{- else -}}
{{- $date := index $data.VendorBoardDate $b.VendorBoard -}}
<td style="background:{{ $date.Color }}"><a href='#{{ $b.VendorBoard }}'>{{ $date }}</a></td>
{{- end}}
<td>{{$b.NorthbridgeNice}}</td>
<td>{{$b.SouthbridgeNice}}</td>
<td>{{$b.SuperIONice}}</td>
<td>{{$b.CPUNice}}</td>
<td>{{$b.SocketNice}}</td>
<td>{{$b.ROMPackage}}</td>
<td>{{$b.ROMProtocol}}</td>
{{template "colorcode" $b.ROMSocketed}}
{{template "colorcode" $b.FlashromSupport}}
<td>{{$b.VendorCooperationScore}}
</td></tr>
{{end -}}
{{end -}}
</table>
<small>
<sup>1</sup> ROM chip package (PLCC, DIP32, DIP8, SOIC8).<br />
<sup>2</sup> ROM chip protocol/type (parallel flash, LPC, FWH, SPI).<br />
<sup>3</sup> ROM chip socketed (Y/N)?<br />
<sup>4</sup> Board supported by [http://www.flashrom.org flashrom] (Y/N)?<br />
<sup>5</sup> Vendor Cooperation Score.<br />
<sup>6</sup> [http://www.flashrom.org flashrom] does not work when the vendor BIOS is booted, but it does work when the machine is booted with coreboot.<br />
<sup>7</sup> Some boards have ROM sockets, others are soldered.<br />
</small>
{{range $t := .Timeframes -}}
<h1>{{$t}}</h1>
{{range $l := index $data.Logs $t -}}
{{if $l.Reference}}<span id="{{$l.VendorBoard}}"></span>
{{end -}}
<a href='https://www.coreboot.org/Board:{{$l.VendorBoard}}'>{{$l.VendorBoard}}</a> at {{$l.TimeReadable}}
<a href='https://review.coreboot.org/plugins/gitiles/coreboot/+/{{$l.Upstream}}'>upstream tree</a> (
{{range $f := $l.Files -}}
<a href='https://review.coreboot.org/plugins/gitiles/board-status/+/refs/heads/master/{{$f.Path}}{{$f.Basename}}'>{{$f.Basename}}</a> {{/* */}}
{{end -}}
)<br />
{{- end -}}
{{end -}}
{{/* */}}
</body>
</html>

View File

@ -0,0 +1,60 @@
package main
import (
"io/fs"
)
type Board struct {
Vendor string
VendorNice string
Vendor2nd string
VendorBoard string
Board string
BoardNice string
BoardURL string
NorthbridgeNice string
SouthbridgeNice string
SuperIONice string
CPUNice string
SocketNice string
ROMPackage string
ROMProtocol string
ROMSocketed string
FlashromSupport string
VendorCooperationScore string
VendorCooperationPage string
}
type Path struct {
Path string
Basename string
}
type Log struct {
Reference bool
VendorBoard string
Time string
TimeReadable string
Upstream string
Files []Path
}
type Category string
type Timeframe string
type DateString string
type TemplateData struct {
Categories []Category
CategoryNiceNames map[Category]string
BoardsByCategory map[Category][]Board
Timeframes []Timeframe
Logs map[Timeframe][]Log
VendorBoardDate map[string]DateString
VendorBoardReferenced map[string]bool
}
type NamedFS struct {
FS fs.FS
Name string
}