a-piece-of-flake (empty) → 0.0.1
raw patch · 50 files changed
+3912/−0 lines, 50 filesdep +QuickCheckdep +a-piece-of-flakedep +acid-statebinary-added
Dependencies added: QuickCheck, a-piece-of-flake, acid-state, add-dependent-file, aeson, aeson-pretty, base, binary, blaze-markup, bytestring, cryptohash-sha1, deepseq, either, exceptions, file-embed, filepath, full-text-search, generics-sop, hostname, http-types, lens, list-t, lrucaching, memory, monad-logger, mtl, network, non-empty, non-negative-time-diff, optparse-applicative, prettyprinter, process, psqueues, ref-tf, refined, regex-tdfa, relude, req, retry, ring-buffer, safecopy, sop-core, statistics, stm, stm-containers, tagged, tar, tasty, tasty-discover, tasty-hunit, tasty-quickcheck, template-haskell, text-zipper, time, time-units, tokenize, trace-embrace, unliftio, vector, wai, wai-extra, warp, warp-tls, wl-pprint-text, yesod-core
Files
- LICENSE +32/−0
- a-piece-of-flake.cabal +244/−0
- app/PieceOfFlake.hs +8/−0
- assets/app.js +206/−0
- assets/app.js.br binary
- assets/app.js.gz binary
- assets/avalanche.mp3 binary
- assets/bulma.min.css +3/−0
- assets/bulma.min.css.br binary
- assets/bulma.min.css.gz binary
- assets/favicon.svg +35/−0
- assets/favicon.svg.br binary
- assets/favicon.svg.gz binary
- assets/flake.svg +50/−0
- assets/flake.svg.br binary
- assets/flake.svg.gz binary
- assets/flush.mp3 binary
- assets/github.svg +31/−0
- assets/github.svg.br binary
- assets/github.svg.gz binary
- assets/robots.txt +5/−0
- assets/sitemap.xml +7/−0
- assets/snow.mp3 binary
- assets/style.css +132/−0
- assets/style.css.br binary
- assets/style.css.gz binary
- changelog.md +4/−0
- src/PieceOfFlake/Acid.hs +57/−0
- src/PieceOfFlake/Aeson.hs +25/−0
- src/PieceOfFlake/CmdArgs.hs +354/−0
- src/PieceOfFlake/CmdRun.hs +102/−0
- src/PieceOfFlake/Fetcher.hs +413/−0
- src/PieceOfFlake/Flake.hs +128/−0
- src/PieceOfFlake/Flake/Repo.hs +398/−0
- src/PieceOfFlake/Http.hs +118/−0
- src/PieceOfFlake/Index.hs +223/−0
- src/PieceOfFlake/Page.hs +654/−0
- src/PieceOfFlake/Prelude.hs +45/−0
- src/PieceOfFlake/Req.hs +78/−0
- src/PieceOfFlake/Stats.hs +142/−0
- src/PieceOfFlake/Stm.hs +16/−0
- src/PieceOfFlake/SubmitList.hs +61/−0
- src/PieceOfFlake/Th.hs +12/−0
- src/PieceOfFlake/TotalMath.hs +15/−0
- src/PieceOfFlake/WebService.hs +49/−0
- src/PieceOfFlake/Yesod.hs +205/−0
- test/Discovery.hs +1/−0
- test/Driver.hs +13/−0
- test/PieceOfFlake/Test/Aeson.hs +23/−0
- trace-embrace.yaml +23/−0
@@ -0,0 +1,32 @@+This module is under this "3 clause" BSD license:++Copyright (c) 2025-2025, Daniil Iaitskov+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are+met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * The names of the contributors may not be used to endorse or+ promote products derived from this software without specific+ prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,244 @@+cabal-version: 3.0+name: a-piece-of-flake+version: 0.0.1++synopsis: Nix flake repository for everyone+description:+ The main idea behind this project is to provide a quick and simple+ interface for publishing Nix flakes.+ + Flakes have become relatively mature and address the central repository+ issue. However, the Nixpkgs repository on GitHub still has more that 5k+ open issues and a comparable number of pull requests, and continues to+ receive many commits every day. Getting a pull request for a new tool+ merged into Nixpkgs can be difficult - the Nixpkgs README explicitly+ discourages people from submitting their “pet” projects.+ + The Nixpkgs repository is huge. It contains more than 120k packages, but+ the majority of them are not native to Nix. For example, about 10% are+ Haskell packages imported. Therefore, this large number cannot be used+ as a reliable measure of how well the publishing process is developed in+ Nix. For instance, the PyPy repository alone currently contains almost+ 900k packages.+ + It is also important to note Python is the most popular general-purpose+ programming language, and its publishing process was designed by+ programmers for programmers. Yet there is no pull-request step in the+ workflow. The interface is essentially “upload and forget”, which has a+ significant positive impact on the conversion funnel of Python packages.+ + Flakes are easy to install, but the publishing workflow is not yet+ polished enough. The current approach to distributing flakes appears to+ have inherinted many characteristics of the Nixpkgs workflow.+ + For Nixpkgs, this was the natural way of development, because all+ derivations form a large and coupled Nix expression split across many+ files within a single Git repository.+ + == Service+ #service#+ + The service is deployed at+ <https://pieceofflakenixrepository.org/ a-piece-of-flake>+ + == Development environment+ #development-environment#+ + > $ nix develop+ > $ emacs &+ > $ cabal build+ > $ cabal test+ + === Update cachix+ #update-cachix#+ + > nix build --no-link --print-out-paths | cachix push piece-of-flake+ + ==== Cachix public key+ #cachix-public-key#+ + > piece-of-flake.cachix.org-1:JamWDEABLpvWhGHIK7Xn/qYFbdZqb6ne7IAF62fXyFY=+ + == Release+ #release#+ + > $ nix build $(e -static true)+ > $ ./result/bin/a-piece-of-flake run+homepage: http://github.com/yaitskov/a-piece-of-flake+license: BSD-3-Clause+license-file: LICENSE+author: Daniil Iaitskov+maintainer: dyaitskov@gmail.com+copyright: Daniil Iaitkov 2026+category: System+build-type: Simple+bug-reports: https://github.com/yaitskov/a-piece-of-flake/issues+extra-source-files:+ assets/favicon.svg+ assets/github.svg+ assets/sitemap.xml+ assets/github.svg+ assets/flake.svg+ assets/app.js+ assets/style.css+ assets/bulma.min.css+ -- brotli+ assets/favicon.svg.br+ assets/github.svg.br+ assets/flake.svg.br+ assets/app.js.br+ assets/style.css.br+ assets/bulma.min.css.br+ -- gzipped+ assets/favicon.svg.gz+ assets/github.svg.gz+ assets/flake.svg.gz+ assets/app.js.gz+ assets/style.css.gz+ assets/bulma.min.css.gz++ assets/robots.txt+ assets/flush.mp3+ assets/snow.mp3+ assets/avalanche.mp3+ trace-embrace.yaml++extra-doc-files:+ changelog.md+tested-with:+ GHC == 9.12.2++source-repository head+ type:+ git+ location:+ https://github.com/yaitskov/a-piece-of-flake.git++common base+ default-language: GHC2024+ ghc-options: -Wall+ default-extensions:+ DefaultSignatures+ DuplicateRecordFields+ NoImplicitPrelude+ OverloadedRecordDot+ OverloadedStrings+ TemplateHaskell+ TypeFamilies+ ViewPatterns+ build-depends:+ , base >=4.7 && < 5+ , optparse-applicative < 1+ , relude >= 1.2.2 && < 2+ , tagged < 1+ , unliftio < 1+ , yesod-core < 1.8++library+ import: base+ hs-source-dirs: src+ exposed-modules:+ PieceOfFlake.Acid+ PieceOfFlake.Aeson+ PieceOfFlake.CmdArgs+ PieceOfFlake.CmdRun+ PieceOfFlake.Fetcher+ PieceOfFlake.Flake+ PieceOfFlake.Flake.Repo+ PieceOfFlake.Http+ PieceOfFlake.Index+ PieceOfFlake.Page+ PieceOfFlake.Prelude+ PieceOfFlake.Req+ PieceOfFlake.Stats+ PieceOfFlake.Stm+ PieceOfFlake.SubmitList+ PieceOfFlake.Th+ PieceOfFlake.TotalMath+ PieceOfFlake.WebService+ PieceOfFlake.Yesod+ other-modules:+ Paths_a_piece_of_flake+ autogen-modules:+ Paths_a_piece_of_flake+ build-depends:+ , acid-state < 1+ , add-dependent-file < 1+ , aeson < 3+ , aeson-pretty < 1+ , binary < 1+ , blaze-markup < 1+ , bytestring < 1+ , cryptohash-sha1 < 1+ , deepseq < 2+ , either < 6+ , exceptions < 1+ , file-embed < 1+ , filepath < 2+ , full-text-search < 1+ , generics-sop < 1+ , hostname < 2+ , http-types < 1+ , lens < 6+ , list-t < 2+ , lrucaching < 1+ , monad-logger < 1+ , memory < 1+ , mtl < 3+ , network < 4+ , non-empty < 1+ , non-negative-time-diff >= 0.0.2 && < 1+ , prettyprinter < 2+ , process < 2+ , psqueues < 1+ , ref-tf < 1+ , refined < 1+ , regex-tdfa < 2+ , req < 4+ , retry < 1+ , ring-buffer < 1+ , safecopy < 1+ , sop-core < 1+ , statistics < 1+ , stm-containers < 2+ , stm < 3+ , tar < 1+ , template-haskell < 3+ , text-zipper < 1+ , time < 2+ , time-units < 2+ , tokenize < 1+ , trace-embrace >= 1.3 && < 2+ , vector < 1+ , wai < 4+ , wai-extra < 4+ , warp < 4+ , warp-tls < 4+ , wl-pprint-text < 2++test-suite test+ import: base+ type: exitcode-stdio-1.0+ main-is: Driver.hs+ other-modules:+ PieceOfFlake.Test.Aeson+ Discovery+ hs-source-dirs:+ test+ ghc-options: -Wall -rtsopts -threaded -main-is Driver+ build-depends:+ , a-piece-of-flake+ , aeson+ , QuickCheck+ , tasty+ , tasty-discover+ , tasty-hunit+ , tasty-quickcheck++executable a-piece-of-flake+ import: base+ ghc-options: -threaded+ main-is: PieceOfFlake.hs+ hs-source-dirs: app+ build-depends:+ , a-piece-of-flake
@@ -0,0 +1,8 @@+module Main where++import PieceOfFlake.CmdArgs+import PieceOfFlake.CmdRun+import PieceOfFlake.Prelude++main :: IO ()+main = execWithArgs runCmd =<< getArgs
@@ -0,0 +1,206 @@+/* a piece of flake+ github:yaitskov/add-dependent-file+*/+const xhrError = (xhr) => {+ if (xhr.responseText && xhr.responseText.length > 1) {+ try {+ const respO = JSON.parse(xhr.responseText);+ if (respO.errors instanceof Array) {+ return respO.errors.join("\n");+ }+ } catch (e) {+ console.error(`${xhr.responseText} => ${e}`);+ }+ return xhr.responseText;+ } else {+ if (xhr.statusText && xhr.statusText.length > 1) {+ return xhr.statusText;+ } else {+ return "Server is down or it is offline";+ }+ }+};++const xhrPost = (urlPath, payload, okCb, opsCb) => {+ const xhr = new XMLHttpRequest();+ xhr.open("POST", urlPath, true);+ xhr.setRequestHeader('Content-Type', 'application/json');+ xhr.setRequestHeader('Accept', 'application/json');+ xhr.send(JSON.stringify(payload));++ xhr.onload = (e) => {+ if (xhr.readyState === 4) {+ if (xhr.status === 200) {+ var r = null;+ try {+ r = JSON.parse(xhr.responseText);+ } catch (je) {+ opsCb(`Failed to parse response as JSON: ${je}`);+ return;+ }+ okCb(r);+ } else {+ opsCb(xhrError(xhr));+ }+ } else {+ opsCb(xhrError(xhr));+ }+ };+ xhr.onerror = (e) => {+ opsCb(`${e} => ${xhrError(xhr)}`);+ };+ return xhr;+};++const ready = () => {+ console.log(`Document ready hook is triggered`);+ const navBarBurger = document.querySelector('#navbar-burger');+ const navBarBurgerCss = navBarBurger.className;+ const navBarMenu = document.querySelector('#navbar-menu');+ const navBarMenuCss = navBarMenu.className;+ const flakeState = document.querySelector('#flake-is-not-indexed');++ if (flakeState) {+ document.addEventListener("visibilitychange", (event) => {+ if (document.visibilityState == "visible") {+ window.location.reload();+ }+ });+ }+++ navBarBurger.addEventListener(+ "click", (e) => {+ if (navBarMenu.className.indexOf("is-active") < 0) {+ navBarMenu.className = `${navBarMenu.className} is-active`;+ navBarBurger.className = `${navBarBurger.className} is-active`;+ } else {+ navBarMenu.className = navBarMenuCss;+ navBarBurger.className = navBarBurgerCss;+ }+ }+ );+};++document.addEventListener("DOMContentLoaded", ready);++const tokenize = s => s.split(/[ \t\n]/).filter(w => w.length > 0);++const flakeViewUrl = (fu) => `/flake/${encodeURIComponent(fu)}`;++const searchFlakesBy = (pattern) => {+ const searchHint = document.querySelector('#search-hint');+ const flakesTbody = document.querySelector('#search-results');+ const foundFlakes = document.querySelector('#found-flakes');+ const errorOutput = document.querySelector('#error-output');+ const noFlakesFound = document.querySelector('#no-flakes-found');+ flakesTbody.innerHTML = "";+ errorOutput.innerHTML = "";+ errorOutput.parentNode.className = "is-hidden";+ foundFlakes.className = "is-hidden";+ noFlakesFound.className = "is-hidden";+ console.log(`Find flakes matching pattern [${pattern}]`);+ xhrPost(+ "find-flakes",+ {skipBroken: false, searchPattern: tokenize(pattern)},+ (foundflakeUrls) => {+ if (foundflakeUrls.length == 0) {+ noFlakesFound.className = "";+ searchHint.parentNode.className = "";+ } else {+ foundFlakes.className = "";+ searchHint.parentNode.className = "is-hidden";+ }+ for (const u in foundflakeUrls) {+ const tr = document.createElement("tr");+ const td = document.createElement("td");+ tr.appendChild(td);+ const a = document.createElement("a");+ td.appendChild(a);+ a.setAttribute('href', flakeViewUrl(foundflakeUrls[u]));+ a.innerText = foundflakeUrls[u];+ flakesTbody.appendChild(tr);+ }+ },+ (err) => {+ errorOutput.innerText = err;+ errorOutput.parentNode.className = "";+ });+ return false;+};++const rewriteUrlToGithubLink = (url) =>+ url.replace(/^https:[/]{2}github[.]com[/]/, "github:");++const startSnowfall = () => {+ const aniflakes = document.getElementById("aniflakes");+ aniflakes.className = "ani-run";+};++const stopSnowfall = () => {+ const aniflakes = document.getElementById("aniflakes");+ aniflakes.className = "";+};++const submitFlake = (url) => {+ const badUrl = document.getElementById("bad-url");++ badUrl.className = "is-hidden";+ url = rewriteUrlToGithubLink(url.trim());+ if (!url.match(/^github:[a-zA-Z0-9._-]+[/][a-zA-Z0-9._-]+$/)) {+ badUrl.className = "";+ return false;+ }+ startSnowfall();+ const sound = Math.random() >= 0.5 ? new Audio("/snow.mp3")+ : (Math.random() >= 0.5 ? new Audio("/avalanche.mp3") : new Audio("/flush.mp3"));++ console.log(`Sumbitting flake ${url}`);++ const sumbittedNotification = document.getElementById("sumbitted-notification");+ const errorOutputHid = document.getElementById("error-output-hid");+ const errorOutput = document.getElementById("error-output");+ const flakeLink = document.getElementById("flake-link");+ flakeLink.setAttribute('href', '#');+ errorOutput.innerText = "";+ errorOutputHid.className = "is-hidden";+ sumbittedNotification.className = "is-hidden";++ xhrPost(+ "submit-flake",+ url,+ (flake) => {+ console.log(`${flake}`);+ switch(flake.tag) {+ case "SubmittedFlake":+ case "FlakeIsBeingFetched":+ /* play sound */+ sound.onended = (e) => {+ window.location = flakeViewUrl(url);+ };+ sound.play();+ flakeLink.setAttribute('href', flakeViewUrl(url));+ sumbittedNotification.className = "";+ break;+ case "FlakeFetched":+ case "FlakeIndexed":+ /* just redirect to flake view page */+ flakeLink.setAttribute('href', flakeViewUrl(url));+ sumbittedNotification.className = "";+ setTimeout(() => window.location = flakeViewUrl(url), 1000);+ break;+ case "BadFlake":+ default:+ stopSnowfall();+ errorOutput.innerText = flake.error;+ errorOutputHid.className = '';+ }+ },+ (err) => {+ stopSnowfall();+ errorOutput.innerText = `Submition of flake ${url} has failed: ${err}`;+ errorOutputHid.className = '';+ }+ );+ return false;+};
binary file changed (absent → 1799 bytes)
binary file changed (absent → 1975 bytes)
binary file changed (absent → 22617 bytes)
@@ -0,0 +1,3 @@+@charset "UTF-8";+/*! bulma.io v1.0.4 | MIT License | github.com/jgthms/bulma */:root{--bulma-control-radius:var(--bulma-radius);--bulma-control-border-width:1px;--bulma-control-height:2.5em;--bulma-control-line-height:1.5;--bulma-control-padding-vertical:calc(0.5em - 1px);--bulma-control-padding-horizontal:calc(0.75em - 1px);--bulma-control-size:var(--bulma-size-normal);--bulma-scheme-h:221;--bulma-scheme-s:14%;--bulma-light-l:90%;--bulma-light-invert-l:20%;--bulma-hover-background-l-delta:-5%;--bulma-active-background-l-delta:-10%;--bulma-hover-border-l-delta:-10%;--bulma-active-border-l-delta:-20%;--bulma-scheme-main-l:100%;--bulma-background-l:96%;--bulma-border-l:86%;--bulma-text-strong-l:21%;--bulma-scheme-invert-l:4%;--bulma-family-primary:Inter,SF Pro,Segoe UI,Roboto,Oxygen,Ubuntu,Helvetica Neue,Helvetica,Arial,sans-serif;--bulma-size-small:0.75rem;--bulma-size-normal:1rem;--bulma-weight-normal:400;--bulma-weight-medium:500;--bulma-weight-semibold:600;--bulma-weight-extrabold:800;--bulma-block-spacing:1.5rem;--bulma-duration:294ms;--bulma-easing:ease-out;--bulma-radius:0.375rem;--bulma-burger-h:var(--bulma-link-h);--bulma-burger-s:var(--bulma-link-s);--bulma-burger-l:var(--bulma-link-l);--bulma-burger-border-radius:0.5em;--bulma-burger-gap:5px;--bulma-burger-item-height:2px;--bulma-burger-item-width:20px;--bulma-black:hsla(var(--bulma-black-h),var(--bulma-black-s),var(--bulma-black-l),1);--bulma-black-h:221deg;--bulma-black-s:14%;--bulma-black-l:4%;--bulma-black-invert-l:100%;--bulma-black-invert:#fff;--bulma-black-on-scheme-l:4%;--bulma-light:hsla(var(--bulma-light-h),var(--bulma-light-s),var(--bulma-light-l),1);--bulma-light-h:221deg;--bulma-light-s:14%;--bulma-light-l:96%;--bulma-light-invert-l:21%;--bulma-light-invert:#2e333d;--bulma-light-on-scheme-l:36%;--bulma-text:hsla(var(--bulma-text-h),var(--bulma-text-s),var(--bulma-text-l),1);--bulma-text-h:221deg;--bulma-text-s:14%;--bulma-text-l:29%;--bulma-link:hsla(var(--bulma-link-h),var(--bulma-link-s),var(--bulma-link-l),1);--bulma-link-h:233deg;--bulma-link-s:100%;--bulma-link-l:63%;--bulma-link-100-l:98%;--bulma-link-invert-l:var(--bulma-link-100-l);--bulma-link-on-scheme-l:58%;--bulma-info:hsla(var(--bulma-info-h),var(--bulma-info-s),var(--bulma-info-l),1);--bulma-info-h:198deg;--bulma-info-s:100%;--bulma-info-l:70%;--bulma-info-10-l:10%;--bulma-info-15-l:15%;--bulma-info-invert-l:var(--bulma-info-10-l);--bulma-info-invert:hsla(var(--bulma-info-h),var(--bulma-info-s),var(--bulma-info-invert-l),1);--bulma-info-light-invert-l:var(--bulma-info-15-l);--bulma-info-on-scheme-l:25%;--bulma-success:hsla(var(--bulma-success-h),var(--bulma-success-s),var(--bulma-success-l),1);--bulma-success-h:153deg;--bulma-success-s:53%;--bulma-success-l:53%;--bulma-success-10-l:8%;--bulma-success-15-l:13%;--bulma-success-invert-l:var(--bulma-success-10-l);--bulma-success-invert:hsla(var(--bulma-success-h),var(--bulma-success-s),var(--bulma-success-invert-l),1);--bulma-success-light-invert-l:var(--bulma-success-15-l);--bulma-success-on-scheme-l:23%;--bulma-warning:hsla(var(--bulma-warning-h),var(--bulma-warning-s),var(--bulma-warning-l),1);--bulma-warning-h:42deg;--bulma-warning-s:100%;--bulma-warning-l:53%;--bulma-warning-10-l:8%;--bulma-warning-15-l:13%;--bulma-warning-invert-l:var(--bulma-warning-10-l);--bulma-warning-invert:hsla(var(--bulma-warning-h),var(--bulma-warning-s),var(--bulma-warning-invert-l),1);--bulma-warning-light-invert-l:var(--bulma-warning-15-l);--bulma-warning-on-scheme-l:23%;--bulma-danger:hsla(var(--bulma-danger-h),var(--bulma-danger-s),var(--bulma-danger-l),1);--bulma-danger-h:348deg;--bulma-danger-s:100%;--bulma-danger-l:70%;--bulma-danger-05-l:5%;--bulma-danger-25-l:25%;--bulma-danger-invert-l:var(--bulma-danger-05-l);--bulma-danger-invert:hsla(var(--bulma-danger-h),var(--bulma-danger-s),var(--bulma-danger-invert-l),1);--bulma-danger-light-invert-l:var(--bulma-danger-25-l);--bulma-danger-on-scheme-l:40%;--bulma-size-3:2rem;--bulma-scheme-main:hsl(var(--bulma-scheme-h),var(--bulma-scheme-s),var(--bulma-scheme-main-l));--bulma-border:hsl(var(--bulma-scheme-h),var(--bulma-scheme-s),var(--bulma-border-l));--bulma-text:hsl(var(--bulma-text-h),var(--bulma-text-s),var(--bulma-text-l));--bulma-text-strong:hsl(var(--bulma-text-h),var(--bulma-text-s),var(--bulma-text-strong-l));--bulma-link:hsl(var(--bulma-link-h),var(--bulma-link-s),var(--bulma-link-l));--bulma-link-text:hsl(var(--bulma-link-h),var(--bulma-link-s),var(--bulma-link-on-scheme-l));--bulma-focus-h:var(--bulma-link-h);--bulma-focus-s:var(--bulma-link-s);--bulma-focus-l:var(--bulma-link-l);--bulma-focus-offset:1px;--bulma-focus-style:solid;--bulma-focus-width:2px;--bulma-focus-shadow-size:0 0 0 0.1875em;--bulma-focus-shadow-alpha:0.25}@media (prefers-color-scheme:light){:root{--bulma-scheme-h:221;--bulma-scheme-s:14%;--bulma-light-l:90%;--bulma-light-invert-l:20%;--bulma-hover-background-l-delta:-5%;--bulma-active-background-l-delta:-10%;--bulma-hover-border-l-delta:-10%;--bulma-active-border-l-delta:-20%;--bulma-scheme-main-l:100%;--bulma-background-l:96%;--bulma-border-l:86%;--bulma-text-strong-l:21%;--bulma-scheme-invert-l:4%;--bulma-family-primary:Inter,SF Pro,Segoe UI,Roboto,Oxygen,Ubuntu,Helvetica Neue,Helvetica,Arial,sans-serif;--bulma-size-small:0.75rem;--bulma-size-normal:1rem;--bulma-weight-normal:400;--bulma-weight-medium:500;--bulma-weight-semibold:600;--bulma-weight-extrabold:800;--bulma-block-spacing:1.5rem;--bulma-duration:294ms;--bulma-easing:ease-out;--bulma-radius:0.375rem;--bulma-burger-h:var(--bulma-link-h);--bulma-burger-s:var(--bulma-link-s);--bulma-burger-l:var(--bulma-link-l);--bulma-burger-border-radius:0.5em;--bulma-burger-gap:5px;--bulma-burger-item-height:2px;--bulma-burger-item-width:20px;--bulma-black:hsla(var(--bulma-black-h),var(--bulma-black-s),var(--bulma-black-l),1);--bulma-black-h:221deg;--bulma-black-s:14%;--bulma-black-l:4%;--bulma-black-invert-l:100%;--bulma-black-invert:#fff;--bulma-black-on-scheme-l:4%;--bulma-light:hsla(var(--bulma-light-h),var(--bulma-light-s),var(--bulma-light-l),1);--bulma-light-h:221deg;--bulma-light-s:14%;--bulma-light-l:96%;--bulma-light-invert-l:21%;--bulma-light-invert:#2e333d;--bulma-light-on-scheme-l:36%;--bulma-text:hsla(var(--bulma-text-h),var(--bulma-text-s),var(--bulma-text-l),1);--bulma-text-h:221deg;--bulma-text-s:14%;--bulma-text-l:29%;--bulma-link:hsla(var(--bulma-link-h),var(--bulma-link-s),var(--bulma-link-l),1);--bulma-link-h:233deg;--bulma-link-s:100%;--bulma-link-l:63%;--bulma-link-100-l:98%;--bulma-link-invert-l:var(--bulma-link-100-l);--bulma-link-on-scheme-l:58%;--bulma-info:hsla(var(--bulma-info-h),var(--bulma-info-s),var(--bulma-info-l),1);--bulma-info-h:198deg;--bulma-info-s:100%;--bulma-info-l:70%;--bulma-info-10-l:10%;--bulma-info-15-l:15%;--bulma-info-invert-l:var(--bulma-info-10-l);--bulma-info-invert:hsla(var(--bulma-info-h),var(--bulma-info-s),var(--bulma-info-invert-l),1);--bulma-info-light-invert-l:var(--bulma-info-15-l);--bulma-info-on-scheme-l:25%;--bulma-success:hsla(var(--bulma-success-h),var(--bulma-success-s),var(--bulma-success-l),1);--bulma-success-h:153deg;--bulma-success-s:53%;--bulma-success-l:53%;--bulma-success-10-l:8%;--bulma-success-15-l:13%;--bulma-success-invert-l:var(--bulma-success-10-l);--bulma-success-invert:hsla(var(--bulma-success-h),var(--bulma-success-s),var(--bulma-success-invert-l),1);--bulma-success-light-invert-l:var(--bulma-success-15-l);--bulma-success-on-scheme-l:23%;--bulma-warning:hsla(var(--bulma-warning-h),var(--bulma-warning-s),var(--bulma-warning-l),1);--bulma-warning-h:42deg;--bulma-warning-s:100%;--bulma-warning-l:53%;--bulma-warning-10-l:8%;--bulma-warning-15-l:13%;--bulma-warning-invert-l:var(--bulma-warning-10-l);--bulma-warning-invert:hsla(var(--bulma-warning-h),var(--bulma-warning-s),var(--bulma-warning-invert-l),1);--bulma-warning-light-invert-l:var(--bulma-warning-15-l);--bulma-warning-on-scheme-l:23%;--bulma-danger:hsla(var(--bulma-danger-h),var(--bulma-danger-s),var(--bulma-danger-l),1);--bulma-danger-h:348deg;--bulma-danger-s:100%;--bulma-danger-l:70%;--bulma-danger-05-l:5%;--bulma-danger-25-l:25%;--bulma-danger-invert-l:var(--bulma-danger-05-l);--bulma-danger-invert:hsla(var(--bulma-danger-h),var(--bulma-danger-s),var(--bulma-danger-invert-l),1);--bulma-danger-light-invert-l:var(--bulma-danger-25-l);--bulma-danger-on-scheme-l:40%;--bulma-size-3:2rem}}@media (prefers-color-scheme:dark){:root{--bulma-black-on-scheme-l:-51%;--bulma-light-on-scheme-l:96%;--bulma-link-on-scheme-l:73%;--bulma-info-on-scheme-l:70%;--bulma-success-on-scheme-l:53%;--bulma-warning-on-scheme-l:53%;--bulma-danger-on-scheme-l:70%;--bulma-scheme-main-l:9%;--bulma-background-l:14%;--bulma-border-l:24%;--bulma-text-l:71%;--bulma-text-strong-l:93%;--bulma-hover-background-l-delta:5%;--bulma-active-background-l-delta:10%;--bulma-hover-border-l-delta:10%;--bulma-active-border-l-delta:20%}}+/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */body,html{margin:0;padding:0}button,input{margin:0}html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}img{height:auto;max-width:100%}table{border-collapse:collapse;border-spacing:0}td{padding:0}td:not([align]){text-align:inherit}:root{--bulma-body-background-color:var(--bulma-scheme-main);--bulma-body-size:1em;--bulma-body-min-width:300px;--bulma-body-rendering:optimizeLegibility;--bulma-body-family:var(--bulma-family-primary);--bulma-body-overflow-x:hidden;--bulma-body-overflow-y:scroll;--bulma-body-color:var(--bulma-text);--bulma-body-font-size:1em;--bulma-body-weight:var(--bulma-weight-normal);--bulma-body-line-height:1.5}html{background-color:var(--bulma-body-background-color);font-size:var(--bulma-body-size);-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:var(--bulma-body-min-width);overflow-x:var(--bulma-body-overflow-x);overflow-y:var(--bulma-body-overflow-y);text-rendering:var(--bulma-body-rendering);text-size-adjust:100%}section{display:block}body,button,input{font-family:var(--bulma-body-family)}body{color:var(--bulma-body-color);font-size:var(--bulma-body-font-size);font-weight:var(--bulma-body-weight);line-height:var(--bulma-body-line-height)}a,button{cursor:pointer}a:focus-visible,button:focus-visible{outline-color:hsl(var(--bulma-focus-h),var(--bulma-focus-s),var(--bulma-focus-l));outline-offset:var(--bulma-focus-offset);outline-style:var(--bulma-focus-style);outline-width:var(--bulma-focus-width)}a:focus-visible:active,button:focus-visible:active{outline-width:1px}a:active,button:active{outline-width:1px}a{color:var(--bulma-link-text);cursor:pointer;text-decoration:none;transition-duration:var(--bulma-duration);transition-property:background-color,border-color,color}button{appearance:none;background:none;border:none;color:inherit;font-family:inherit;font-size:1em;margin:0;padding:0;transition-duration:var(--bulma-duration);transition-property:background-color,border-color,color}img{height:auto;max-width:100%}span{font-style:inherit;font-weight:inherit}svg{height:auto;width:auto}table td{vertical-align:top}table td:not([align]){text-align:inherit}@keyframes spinAround{0%{transform:rotate(0deg)}to{transform:rotate(359deg)}}@keyframes pulsate{50%{opacity:.5}}.content:not(:last-child),.field:not(:last-child),.message:not(:last-child),.notification:not(:last-child),.table:not(:last-child),.title:not(:last-child){margin-bottom:var(--bulma-block-spacing)}.button,.input{align-items:center;appearance:none;border-color:transparent;border-radius:var(--bulma-control-radius);border-style:solid;border-width:var(--bulma-control-border-width);box-shadow:none;display:inline-flex;font-size:var(--bulma-control-size);height:var(--bulma-control-height);justify-content:flex-start;line-height:var(--bulma-control-line-height);padding:var(--bulma-control-padding-vertical) var(--bulma-control-padding-horizontal);position:relative;transition-duration:var(--bulma-duration);transition-property:background-color,border-color,box-shadow,color;vertical-align:top}.button:active,.button:focus,.button:focus-visible,.button:focus-within,.input:active,.input:focus,.input:focus-visible,.input:focus-within,.is-active.button,.is-active.input{outline:none}.navbar-burger{appearance:none;background:none;border:none;color:inherit;font-family:inherit;font-size:1em;margin:0;padding:0}.button{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.button{--bulma-button-weight:var(--bulma-weight-medium);--bulma-button-border-style:solid;--bulma-button-border-width:var(--bulma-control-border-width);--bulma-button-padding-vertical:0.5em;--bulma-button-padding-horizontal:1em;--bulma-button-h:var(--bulma-scheme-h);--bulma-button-s:var(--bulma-scheme-s);--bulma-button-background-l:var(--bulma-scheme-main-l);--bulma-button-background-l-delta:0%;--bulma-button-hover-background-l-delta:var(--bulma-hover-background-l-delta);--bulma-button-active-background-l-delta:var(--bulma-active-background-l-delta);--bulma-button-color-l:var(--bulma-text-strong-l);--bulma-button-border-l:var(--bulma-border-l);--bulma-button-border-l-delta:0%;--bulma-button-hover-border-l-delta:var(--bulma-hover-border-l-delta);--bulma-button-active-border-l-delta:var(--bulma-active-border-l-delta);--bulma-button-outer-shadow-h:0;--bulma-button-outer-shadow-s:0%;--bulma-button-outer-shadow-l:20%;--bulma-button-outer-shadow-a:0.05;background-color:hsl(var(--bulma-button-h),var(--bulma-button-s),calc(var(--bulma-button-background-l) + var(--bulma-button-background-l-delta)));border-color:hsl(var(--bulma-button-h),var(--bulma-button-s),calc(var(--bulma-button-border-l) + var(--bulma-button-border-l-delta)));border-style:var(--bulma-button-border-style);border-width:var(--bulma-button-border-width);box-shadow:0 .0625em .125em hsla(var(--bulma-button-outer-shadow-h),var(--bulma-button-outer-shadow-s),var(--bulma-button-outer-shadow-l),var(--bulma-button-outer-shadow-a)),0 .125em .25em hsla(var(--bulma-button-outer-shadow-h),var(--bulma-button-outer-shadow-s),var(--bulma-button-outer-shadow-l),var(--bulma-button-outer-shadow-a));color:hsl(var(--bulma-button-h),var(--bulma-button-s),var(--bulma-button-color-l));cursor:pointer;font-weight:var(--bulma-button-weight);height:auto;justify-content:center;padding:calc(var(--bulma-button-padding-vertical) - var(--bulma-button-border-width)) calc(var(--bulma-button-padding-horizontal) - var(--bulma-button-border-width));text-align:center;white-space:nowrap}.button .icon{height:1.5em;width:1.5em}.button .icon:first-child:not(:last-child){margin-inline-end:calc(var(--bulma-button-padding-horizontal)*.25);margin-inline-start:calc(var(--bulma-button-padding-horizontal)*-.5)}.button .icon:last-child:not(:first-child){margin-inline-end:calc(var(--bulma-button-padding-horizontal)*-.5);margin-inline-start:calc(var(--bulma-button-padding-horizontal)*.25)}.button .icon:first-child:last-child{margin-inline-end:calc(var(--bulma-button-padding-horizontal)*-.5);margin-inline-start:calc(var(--bulma-button-padding-horizontal)*-.5)}.button:hover{--bulma-button-background-l-delta:var(--bulma-button-hover-background-l-delta);--bulma-button-border-l-delta:var(--bulma-button-hover-border-l-delta)}.button:focus-visible{--bulma-button-border-width:1px;border-color:hsl(var(--bulma-focus-h),var(--bulma-focus-s),var(--bulma-focus-l));box-shadow:var(--bulma-focus-shadow-size) hsla(var(--bulma-focus-h),var(--bulma-focus-s),var(--bulma-focus-l),var(--bulma-focus-shadow-alpha))}.button.is-active,.button:active{--bulma-button-background-l-delta:var(--bulma-button-active-background-l-delta);--bulma-button-border-l-delta:var(--bulma-button-active-border-l-delta);--bulma-button-outer-shadow-a:0}.button.is-black{--bulma-button-h:var(--bulma-black-h);--bulma-button-s:var(--bulma-black-s);--bulma-button-background-l:var(--bulma-black-l);--bulma-button-border-l:var(--bulma-black-l);--bulma-button-border-width:0px;--bulma-button-color-l:var(--bulma-black-invert-l);--bulma-button-outer-shadow-a:0}.button.is-black:focus-visible{--bulma-button-border-width:1px}.button.is-light{--bulma-button-h:var(--bulma-light-h);--bulma-button-s:var(--bulma-light-s);--bulma-button-background-l:var(--bulma-light-l);--bulma-button-border-l:var(--bulma-light-l);--bulma-button-border-width:0px;--bulma-button-color-l:var(--bulma-light-invert-l);--bulma-button-outer-shadow-a:0}.button.is-light:focus-visible{--bulma-button-border-width:1px}.button.is-info{--bulma-button-h:var(--bulma-info-h);--bulma-button-s:var(--bulma-info-s);--bulma-button-background-l:var(--bulma-info-l);--bulma-button-border-l:var(--bulma-info-l);--bulma-button-border-width:0px;--bulma-button-color-l:var(--bulma-info-invert-l);--bulma-button-outer-shadow-a:0}.button.is-info:focus-visible{--bulma-button-border-width:1px}.button.is-info.is-light{--bulma-button-background-l:var(--bulma-light-l);--bulma-button-color-l:var(--bulma-info-light-invert-l)}.button.is-success{--bulma-button-h:var(--bulma-success-h);--bulma-button-s:var(--bulma-success-s);--bulma-button-background-l:var(--bulma-success-l);--bulma-button-border-l:var(--bulma-success-l);--bulma-button-border-width:0px;--bulma-button-color-l:var(--bulma-success-invert-l);--bulma-button-outer-shadow-a:0}.button.is-success:focus-visible{--bulma-button-border-width:1px}.button.is-success.is-light{--bulma-button-background-l:var(--bulma-light-l);--bulma-button-color-l:var(--bulma-success-light-invert-l)}.button.is-warning{--bulma-button-h:var(--bulma-warning-h);--bulma-button-s:var(--bulma-warning-s);--bulma-button-background-l:var(--bulma-warning-l);--bulma-button-border-l:var(--bulma-warning-l);--bulma-button-border-width:0px;--bulma-button-color-l:var(--bulma-warning-invert-l);--bulma-button-outer-shadow-a:0}.button.is-warning:focus-visible{--bulma-button-border-width:1px}.button.is-warning.is-light{--bulma-button-background-l:var(--bulma-light-l);--bulma-button-color-l:var(--bulma-warning-light-invert-l)}.button.is-danger{--bulma-button-h:var(--bulma-danger-h);--bulma-button-s:var(--bulma-danger-s);--bulma-button-background-l:var(--bulma-danger-l);--bulma-button-border-l:var(--bulma-danger-l);--bulma-button-border-width:0px;--bulma-button-color-l:var(--bulma-danger-invert-l);--bulma-button-outer-shadow-a:0}.button.is-danger:focus-visible{--bulma-button-border-width:1px}.button.is-danger.is-light{--bulma-button-background-l:var(--bulma-light-l);--bulma-button-color-l:var(--bulma-danger-light-invert-l)}.content{--bulma-content-block-margin-bottom:1em;--bulma-content-table-cell-border:1px solid var(--bulma-border);--bulma-content-table-cell-border-width:0 0 1px;--bulma-content-table-cell-padding:0.5em 0.75em}.content table:not(:last-child){margin-bottom:var(--bulma-content-block-margin-bottom)}.content table td{border:var(--bulma-content-table-cell-border);border-width:var(--bulma-content-table-cell-border-width);padding:var(--bulma-content-table-cell-padding);vertical-align:top}.icon{--bulma-icon-dimensions:1.5rem}.icon{align-items:center;display:inline-flex;flex-shrink:0;height:var(--bulma-icon-dimensions);justify-content:center;transition-duration:var(--bulma-duration);transition-property:color;width:var(--bulma-icon-dimensions)}.image{display:block;position:relative}.image img{display:block;height:auto;width:100%}.notification{--bulma-notification-h:var(--bulma-scheme-h);--bulma-notification-s:var(--bulma-scheme-s);--bulma-notification-background-l:var(--bulma-background-l);--bulma-notification-color-l:var(--bulma-text-strong-l);--bulma-notification-radius:var(--bulma-radius);--bulma-notification-padding:1.375em 1.5em;background-color:hsl(var(--bulma-notification-h),var(--bulma-notification-s),var(--bulma-notification-background-l));border-radius:var(--bulma-notification-radius);color:hsl(var(--bulma-notification-h),var(--bulma-notification-s),var(--bulma-notification-color-l));padding:var(--bulma-notification-padding);position:relative}.notification a:not(.button):not(.dropdown-item){color:currentColor;text-decoration:underline}.notification .content,.notification .title{color:currentColor}.notification.is-black{--bulma-notification-h:var(--bulma-black-h);--bulma-notification-s:var(--bulma-black-s);--bulma-notification-background-l:var(--bulma-black-l);--bulma-notification-color-l:var(--bulma-black-invert-l)}.notification.is-black.is-light{--bulma-notification-background-l:90%;--bulma-notification-color-l:var(--bulma-black-light-invert-l)}.notification.is-light{--bulma-notification-h:var(--bulma-light-h);--bulma-notification-s:var(--bulma-light-s);--bulma-notification-background-l:var(--bulma-light-l);--bulma-notification-color-l:var(--bulma-light-invert-l)}.notification.is-light.is-light{--bulma-notification-background-l:90%;--bulma-notification-color-l:var(--bulma-light-light-invert-l)}.notification.is-info{--bulma-notification-h:var(--bulma-info-h);--bulma-notification-s:var(--bulma-info-s);--bulma-notification-background-l:var(--bulma-info-l);--bulma-notification-color-l:var(--bulma-info-invert-l)}.notification.is-info.is-light{--bulma-notification-background-l:90%;--bulma-notification-color-l:var(--bulma-info-light-invert-l)}.notification.is-success{--bulma-notification-h:var(--bulma-success-h);--bulma-notification-s:var(--bulma-success-s);--bulma-notification-background-l:var(--bulma-success-l);--bulma-notification-color-l:var(--bulma-success-invert-l)}.notification.is-success.is-light{--bulma-notification-background-l:90%;--bulma-notification-color-l:var(--bulma-success-light-invert-l)}.notification.is-warning{--bulma-notification-h:var(--bulma-warning-h);--bulma-notification-s:var(--bulma-warning-s);--bulma-notification-background-l:var(--bulma-warning-l);--bulma-notification-color-l:var(--bulma-warning-invert-l)}.notification.is-warning.is-light{--bulma-notification-background-l:90%;--bulma-notification-color-l:var(--bulma-warning-light-invert-l)}.notification.is-danger{--bulma-notification-h:var(--bulma-danger-h);--bulma-notification-s:var(--bulma-danger-s);--bulma-notification-background-l:var(--bulma-danger-l);--bulma-notification-color-l:var(--bulma-danger-invert-l)}.notification.is-danger.is-light{--bulma-notification-background-l:90%;--bulma-notification-color-l:var(--bulma-danger-light-invert-l)}@keyframes moveIndeterminate{0%{background-position:200% 0}to{background-position:-200% 0}}.table{--bulma-table-color:var(--bulma-text-strong);--bulma-table-background-color:var(--bulma-scheme-main);--bulma-table-cell-border-color:var(--bulma-border);--bulma-table-cell-border-style:solid;--bulma-table-cell-border-width:0 0 1px;--bulma-table-cell-padding:0.5em 0.75em;background-color:var(--bulma-table-background-color)}.table,.table td{color:var(--bulma-table-color)}.table td{background-color:var(--bulma-table-cell-background-color);border-color:var(--bulma-table-cell-border-color);border-style:var(--bulma-table-cell-border-style);border-width:var(--bulma-table-cell-border-width);padding:var(--bulma-table-cell-padding);vertical-align:top}.table td.is-black{--bulma-table-color:var(--bulma-black-invert);--bulma-table-cell-background-color:var(--bulma-black);--bulma-table-cell-border-color:var(--bulma-black)}.table td.is-light{--bulma-table-color:var(--bulma-light-invert);--bulma-table-cell-background-color:var(--bulma-light);--bulma-table-cell-border-color:var(--bulma-light)}.table td.is-info{--bulma-table-color:var(--bulma-info-invert);--bulma-table-cell-background-color:var(--bulma-info);--bulma-table-cell-border-color:var(--bulma-info)}.table td.is-success{--bulma-table-color:var(--bulma-success-invert);--bulma-table-cell-background-color:var(--bulma-success);--bulma-table-cell-border-color:var(--bulma-success)}.table td.is-warning{--bulma-table-color:var(--bulma-warning-invert);--bulma-table-cell-background-color:var(--bulma-warning);--bulma-table-cell-border-color:var(--bulma-warning)}.table td.is-danger{--bulma-table-color:var(--bulma-danger-invert);--bulma-table-cell-background-color:var(--bulma-danger);--bulma-table-cell-border-color:var(--bulma-danger)}.table tr.is-black{--bulma-table-color:var(--bulma-black-invert);--bulma-table-cell-background-color:var(--bulma-black);--bulma-table-cell-border-color:var(--bulma-black)}.table tr.is-light{--bulma-table-color:var(--bulma-light-invert);--bulma-table-cell-background-color:var(--bulma-light);--bulma-table-cell-border-color:var(--bulma-light)}.table tr.is-info{--bulma-table-color:var(--bulma-info-invert);--bulma-table-cell-background-color:var(--bulma-info);--bulma-table-cell-border-color:var(--bulma-info)}.table tr.is-success{--bulma-table-color:var(--bulma-success-invert);--bulma-table-cell-background-color:var(--bulma-success);--bulma-table-cell-border-color:var(--bulma-success)}.table tr.is-warning{--bulma-table-color:var(--bulma-warning-invert);--bulma-table-cell-background-color:var(--bulma-warning);--bulma-table-cell-border-color:var(--bulma-warning)}.table tr.is-danger{--bulma-table-color:var(--bulma-danger-invert);--bulma-table-cell-background-color:var(--bulma-danger);--bulma-table-cell-border-color:var(--bulma-danger)}.table.is-bordered td{border-width:1px}.table.is-bordered tr:last-child td{border-bottom-width:1px}.title{--bulma-title-color:var(--bulma-text-strong);--bulma-title-size:var(--bulma-size-3);--bulma-title-weight:var(--bulma-weight-extrabold);--bulma-title-line-height:1.125;word-break:break-word}.title span{font-weight:inherit}.title{color:var(--bulma-title-color);font-size:var(--bulma-title-size);font-weight:var(--bulma-title-weight);line-height:var(--bulma-title-line-height)}.title:not(.is-spaced):has(+.subtitle){margin-bottom:0}.title.is-4{font-size:1.5rem}.title.is-5{font-size:1.25rem}.title.is-6{font-size:1rem}.control,.input{--bulma-input-h:var(--bulma-scheme-h);--bulma-input-s:var(--bulma-scheme-s);--bulma-input-border-l:var(--bulma-border-l);--bulma-input-border-l-delta:0%;--bulma-input-border-color:hsl(var(--bulma-input-h),var(--bulma-input-s),calc(var(--bulma-input-border-l) + var(--bulma-input-border-l-delta)));--bulma-input-hover-border-l-delta:var(--bulma-hover-border-l-delta);--bulma-input-active-border-l-delta:var(--bulma-active-border-l-delta);--bulma-input-focus-h:var(--bulma-focus-h);--bulma-input-focus-s:var(--bulma-focus-s);--bulma-input-focus-l:var(--bulma-focus-l);--bulma-input-focus-shadow-size:var(--bulma-focus-shadow-size);--bulma-input-focus-shadow-alpha:var(--bulma-focus-shadow-alpha);--bulma-input-color-l:var(--bulma-text-strong-l);--bulma-input-background-l:var(--bulma-scheme-main-l);--bulma-input-background-l-delta:0%;--bulma-input-placeholder-color:hsla(var(--bulma-text-h),var(--bulma-text-s),var(--bulma-text-strong-l),0.3);--bulma-input-radius:var(--bulma-radius)}.input{background-color:hsl(var(--bulma-input-h),var(--bulma-input-s),calc(var(--bulma-input-background-l) + var(--bulma-input-background-l-delta)));border-color:var(--bulma-input-border-color);border-radius:var(--bulma-input-radius);color:hsl(var(--bulma-input-h),var(--bulma-input-s),var(--bulma-input-color-l))}.input::-moz-placeholder{color:var(--bulma-input-placeholder-color)}.input::-webkit-input-placeholder{color:var(--bulma-input-placeholder-color)}.input:-moz-placeholder{color:var(--bulma-input-placeholder-color)}.input:-ms-input-placeholder{color:var(--bulma-input-placeholder-color)}.input:hover{--bulma-input-border-l-delta:var(--bulma-input-hover-border-l-delta)}.input:active,.is-active.input{--bulma-input-border-l-delta:var(--bulma-input-active-border-l-delta)}.input:focus,.input:focus-within{border-color:hsl(var(--bulma-input-focus-h),var(--bulma-input-focus-s),var(--bulma-input-focus-l));box-shadow:var(--bulma-input-focus-shadow-size) hsla(var(--bulma-input-focus-h),var(--bulma-input-focus-s),var(--bulma-input-focus-l),var(--bulma-input-focus-shadow-alpha))}.input{box-shadow:inset 0 .0625em .125em hsla(var(--bulma-scheme-h),var(--bulma-scheme-s),var(--bulma-scheme-invert-l),.05);max-width:100%;width:100%}.is-black.input{--bulma-input-h:var(--bulma-black-h);--bulma-input-s:var(--bulma-black-s);--bulma-input-focus-h:var(--bulma-black-h);--bulma-input-focus-s:var(--bulma-black-s);--bulma-input-focus-l:var(--bulma-black-l);--bulma-input-border-l:var(--bulma-black-l)}.is-light.input{--bulma-input-h:var(--bulma-light-h);--bulma-input-s:var(--bulma-light-s);--bulma-input-focus-h:var(--bulma-light-h);--bulma-input-focus-s:var(--bulma-light-s);--bulma-input-focus-l:var(--bulma-light-l);--bulma-input-border-l:var(--bulma-light-l)}.is-info.input{--bulma-input-h:var(--bulma-info-h);--bulma-input-s:var(--bulma-info-s);--bulma-input-focus-h:var(--bulma-info-h);--bulma-input-focus-s:var(--bulma-info-s);--bulma-input-focus-l:var(--bulma-info-l);--bulma-input-border-l:var(--bulma-info-l)}.is-success.input{--bulma-input-h:var(--bulma-success-h);--bulma-input-s:var(--bulma-success-s);--bulma-input-focus-h:var(--bulma-success-h);--bulma-input-focus-s:var(--bulma-success-s);--bulma-input-focus-l:var(--bulma-success-l);--bulma-input-border-l:var(--bulma-success-l)}.is-warning.input{--bulma-input-h:var(--bulma-warning-h);--bulma-input-s:var(--bulma-warning-s);--bulma-input-focus-h:var(--bulma-warning-h);--bulma-input-focus-s:var(--bulma-warning-s);--bulma-input-focus-l:var(--bulma-warning-l);--bulma-input-border-l:var(--bulma-warning-l)}.is-danger.input{--bulma-input-h:var(--bulma-danger-h);--bulma-input-s:var(--bulma-danger-s);--bulma-input-focus-h:var(--bulma-danger-h);--bulma-input-focus-s:var(--bulma-danger-s);--bulma-input-focus-l:var(--bulma-danger-l);--bulma-input-border-l:var(--bulma-danger-l)}:root{--bulma-label-color:var(--bulma-text-strong);--bulma-label-spacing:0.5em;--bulma-help-size:var(--bulma-size-small);--bulma-field-block-spacing:0.75rem}.label{color:var(--bulma-label-color);display:block;font-size:var(--bulma-size-normal);font-weight:var(--bulma-weight-semibold)}.label:not(:last-child){margin-bottom:var(--bulma-label-spacing)}.help{display:block;font-size:var(--bulma-help-size);margin-top:.25rem}.help.is-black{color:hsl(var(--bulma-black-h),var(--bulma-black-s),var(--bulma-black-on-scheme-l))}.help.is-light{color:hsl(var(--bulma-light-h),var(--bulma-light-s),var(--bulma-light-on-scheme-l))}.help.is-info{color:hsl(var(--bulma-info-h),var(--bulma-info-s),var(--bulma-info-on-scheme-l))}.help.is-success{color:hsl(var(--bulma-success-h),var(--bulma-success-s),var(--bulma-success-on-scheme-l))}.help.is-warning{color:hsl(var(--bulma-warning-h),var(--bulma-warning-s),var(--bulma-warning-on-scheme-l))}.help.is-danger{color:hsl(var(--bulma-danger-h),var(--bulma-danger-s),var(--bulma-danger-on-scheme-l))}.field{--bulma-block-spacing:var(--bulma-field-block-spacing)}.field.is-grouped{display:flex;gap:.75rem;justify-content:flex-start}.field.is-grouped>.control{flex-shrink:0}.control{box-sizing:border-box;clear:both;font-size:var(--bulma-size-normal);position:relative;text-align:inherit}.message{--bulma-message-radius:var(--bulma-radius);--bulma-message-h:var(--bulma-scheme-h);--bulma-message-s:var(--bulma-scheme-s);--bulma-message-color-l:var(--bulma-text-l);border-radius:var(--bulma-message-radius);color:hsl(var(--bulma-message-h),var(--bulma-message-s),var(--bulma-message-color-l));font-size:var(--bulma-size-normal)}.message a:not(.button):not(.tag):not(.dropdown-item){color:currentColor;text-decoration:underline}.message.is-black{--bulma-message-h:var(--bulma-black-h);--bulma-message-s:var(--bulma-black-s);--bulma-message-color-l:var(--bulma-black-on-scheme-l)}.message.is-light{--bulma-message-h:var(--bulma-light-h);--bulma-message-s:var(--bulma-light-s);--bulma-message-color-l:var(--bulma-light-on-scheme-l)}.message.is-info{--bulma-message-h:var(--bulma-info-h);--bulma-message-s:var(--bulma-info-s);--bulma-message-color-l:var(--bulma-info-on-scheme-l)}.message.is-success{--bulma-message-h:var(--bulma-success-h);--bulma-message-s:var(--bulma-success-s);--bulma-message-color-l:var(--bulma-success-on-scheme-l)}.message.is-warning{--bulma-message-h:var(--bulma-warning-h);--bulma-message-s:var(--bulma-warning-s);--bulma-message-color-l:var(--bulma-warning-on-scheme-l)}.message.is-danger{--bulma-message-h:var(--bulma-danger-h);--bulma-message-s:var(--bulma-danger-s);--bulma-message-color-l:var(--bulma-danger-on-scheme-l)}:root{--bulma-navbar-height:3.25rem}.navbar{--bulma-navbar-h:var(--bulma-scheme-h);--bulma-navbar-s:var(--bulma-scheme-s);--bulma-navbar-background-color:var(--bulma-scheme-main);--bulma-navbar-z:30;--bulma-navbar-item-background-a:0;--bulma-navbar-item-background-l:var(--bulma-scheme-main-l);--bulma-navbar-item-background-l-delta:0%;--bulma-navbar-item-hover-background-l-delta:var(--bulma-hover-background-l-delta);--bulma-navbar-item-active-background-l-delta:var(--bulma-active-background-l-delta);--bulma-navbar-item-color-l:var(--bulma-text-l);--bulma-navbar-item-color:hsl(var(--bulma-navbar-h),var(--bulma-navbar-s),var(--bulma-navbar-item-color-l));--bulma-navbar-item-selected-h:var(--bulma-link-h);--bulma-navbar-item-selected-s:var(--bulma-link-s);--bulma-navbar-item-selected-background-l:var(--bulma-link-l);--bulma-navbar-item-selected-color-l:var(--bulma-link-invert-l);--bulma-navbar-item-img-max-height:1.75rem;--bulma-navbar-burger-color:var(--bulma-link);background-color:var(--bulma-navbar-background-color);min-height:var(--bulma-navbar-height);position:relative;z-index:var(--bulma-navbar-z)}.navbar.is-black{--bulma-navbar-h:var(--bulma-black-h);--bulma-navbar-s:var(--bulma-black-s);--bulma-burger-h:var(--bulma-black-h);--bulma-burger-s:var(--bulma-black-s);--bulma-burger-l:var(--bulma-black-invert-l);--bulma-navbar-background-color:var(--bulma-black);--bulma-navbar-item-background-l:var(--bulma-black-l);--bulma-navbar-item-color-l:var(--bulma-black-invert-l);--bulma-navbar-item-selected-h:var(--bulma-black-h);--bulma-navbar-item-selected-s:var(--bulma-black-s);--bulma-navbar-item-selected-background-l:var(--bulma-black-l);--bulma-navbar-item-selected-color-l:var(--bulma-black-invert-l)}.navbar.is-light{--bulma-navbar-h:var(--bulma-light-h);--bulma-navbar-s:var(--bulma-light-s);--bulma-burger-h:var(--bulma-light-h);--bulma-burger-s:var(--bulma-light-s);--bulma-burger-l:var(--bulma-light-invert-l);--bulma-navbar-background-color:var(--bulma-light);--bulma-navbar-item-background-l:var(--bulma-light-l);--bulma-navbar-item-color-l:var(--bulma-light-invert-l);--bulma-navbar-item-selected-h:var(--bulma-light-h);--bulma-navbar-item-selected-s:var(--bulma-light-s);--bulma-navbar-item-selected-background-l:var(--bulma-light-l);--bulma-navbar-item-selected-color-l:var(--bulma-light-invert-l)}.navbar.is-info{--bulma-navbar-h:var(--bulma-info-h);--bulma-navbar-s:var(--bulma-info-s);--bulma-burger-h:var(--bulma-info-h);--bulma-burger-s:var(--bulma-info-s);--bulma-burger-l:var(--bulma-info-invert-l);--bulma-navbar-background-color:var(--bulma-info);--bulma-navbar-item-background-l:var(--bulma-info-l);--bulma-navbar-item-color-l:var(--bulma-info-invert-l);--bulma-navbar-item-selected-h:var(--bulma-info-h);--bulma-navbar-item-selected-s:var(--bulma-info-s);--bulma-navbar-item-selected-background-l:var(--bulma-info-l);--bulma-navbar-item-selected-color-l:var(--bulma-info-invert-l)}.navbar.is-success{--bulma-navbar-h:var(--bulma-success-h);--bulma-navbar-s:var(--bulma-success-s);--bulma-burger-h:var(--bulma-success-h);--bulma-burger-s:var(--bulma-success-s);--bulma-burger-l:var(--bulma-success-invert-l);--bulma-navbar-background-color:var(--bulma-success);--bulma-navbar-item-background-l:var(--bulma-success-l);--bulma-navbar-item-color-l:var(--bulma-success-invert-l);--bulma-navbar-item-selected-h:var(--bulma-success-h);--bulma-navbar-item-selected-s:var(--bulma-success-s);--bulma-navbar-item-selected-background-l:var(--bulma-success-l);--bulma-navbar-item-selected-color-l:var(--bulma-success-invert-l)}.navbar.is-warning{--bulma-navbar-h:var(--bulma-warning-h);--bulma-navbar-s:var(--bulma-warning-s);--bulma-burger-h:var(--bulma-warning-h);--bulma-burger-s:var(--bulma-warning-s);--bulma-burger-l:var(--bulma-warning-invert-l);--bulma-navbar-background-color:var(--bulma-warning);--bulma-navbar-item-background-l:var(--bulma-warning-l);--bulma-navbar-item-color-l:var(--bulma-warning-invert-l);--bulma-navbar-item-selected-h:var(--bulma-warning-h);--bulma-navbar-item-selected-s:var(--bulma-warning-s);--bulma-navbar-item-selected-background-l:var(--bulma-warning-l);--bulma-navbar-item-selected-color-l:var(--bulma-warning-invert-l)}.navbar.is-danger{--bulma-navbar-h:var(--bulma-danger-h);--bulma-navbar-s:var(--bulma-danger-s);--bulma-burger-h:var(--bulma-danger-h);--bulma-burger-s:var(--bulma-danger-s);--bulma-burger-l:var(--bulma-danger-invert-l);--bulma-navbar-background-color:var(--bulma-danger);--bulma-navbar-item-background-l:var(--bulma-danger-l);--bulma-navbar-item-color-l:var(--bulma-danger-invert-l);--bulma-navbar-item-selected-h:var(--bulma-danger-h);--bulma-navbar-item-selected-s:var(--bulma-danger-s);--bulma-navbar-item-selected-background-l:var(--bulma-danger-l);--bulma-navbar-item-selected-color-l:var(--bulma-danger-invert-l)}.navbar-brand{align-items:stretch;display:flex;flex-shrink:0;min-height:var(--bulma-navbar-height)}.navbar-burger{align-items:center;appearance:none;background:none;border:none;border-radius:var(--bulma-burger-border-radius);color:hsl(var(--bulma-burger-h),var(--bulma-burger-s),var(--bulma-burger-l));cursor:pointer;display:inline-flex;flex-direction:column;flex-shrink:0;height:2.5rem;justify-content:center;position:relative;vertical-align:top;width:2.5rem}.navbar-burger span{background-color:currentColor;display:block;height:var(--bulma-burger-item-height);left:calc(50% - (var(--bulma-burger-item-width))/2);position:absolute;transform-origin:center;transition-duration:var(--bulma-duration);transition-property:background-color,color,opacity,transform;transition-timing-function:var(--bulma-easing);width:var(--bulma-burger-item-width)}.navbar-burger span:first-child,.navbar-burger span:nth-child(2){top:calc(50% - (var(--bulma-burger-item-height))/2)}.navbar-burger span:nth-child(3){bottom:calc(50% + var(--bulma-burger-gap))}.navbar-burger span:nth-child(4){top:calc(50% + var(--bulma-burger-gap))}.navbar-burger:hover{background-color:hsla(var(--bulma-burger-h),var(--bulma-burger-s),var(--bulma-burger-l),.1)}.navbar-burger:active{background-color:hsla(var(--bulma-burger-h),var(--bulma-burger-s),var(--bulma-burger-l),.2)}.navbar-burger.is-active span:first-child{transform:rotate(-45deg)}.navbar-burger.is-active span:nth-child(2){transform:rotate(45deg)}.navbar-burger.is-active span:nth-child(3),.navbar-burger.is-active span:nth-child(4){opacity:0}.navbar-burger{align-self:center;color:var(--bulma-navbar-burger-color);margin-inline-end:.375rem;margin-inline-start:auto}.navbar-menu{display:none}.navbar-item{color:var(--bulma-navbar-item-color);display:block;gap:.75rem;line-height:1.5;padding:.5rem .75rem;position:relative}.navbar-item .icon:only-child{margin-left:-.25rem;margin-right:-.25rem}a.navbar-item{background-color:hsla(var(--bulma-navbar-h),var(--bulma-navbar-s),calc(var(--bulma-navbar-item-background-l) + var(--bulma-navbar-item-background-l-delta)),var(--bulma-navbar-item-background-a));cursor:pointer}a.navbar-item:focus,a.navbar-item:focus-within,a.navbar-item:hover{--bulma-navbar-item-background-l-delta:var(--bulma-navbar-item-hover-background-l-delta);--bulma-navbar-item-background-a:1}a.navbar-item:active{--bulma-navbar-item-background-l-delta:var(--bulma-navbar-item-active-background-l-delta);--bulma-navbar-item-background-a:1}a.navbar-item.is-active{--bulma-navbar-h:var(--bulma-navbar-item-selected-h);--bulma-navbar-s:var(--bulma-navbar-item-selected-s);--bulma-navbar-item-background-l:var(--bulma-navbar-item-selected-background-l);--bulma-navbar-item-background-a:1;--bulma-navbar-item-color-l:var(--bulma-navbar-item-selected-color-l)}.navbar-item{flex-grow:0;flex-shrink:0}.navbar-item img,.navbar-item svg{max-height:var(--bulma-navbar-item-img-max-height)}@media screen and (max-width:1023px){.navbar-brand .navbar-item{align-items:center;display:flex}.navbar-menu{background-color:var(--bulma-navbar-background-color);box-shadow:0 .5em 1em hsla(var(--bulma-scheme-h),var(--bulma-scheme-s),var(--bulma-scheme-invert-l),.1);padding:.5rem 0}.navbar-menu.is-active{display:block}}@media screen and (min-width:1024px){.navbar,.navbar-menu,.navbar-start{align-items:stretch;display:flex}.navbar{min-height:var(--bulma-navbar-height)}.navbar-burger{display:none}.navbar-item{align-items:center;display:flex}.navbar-menu{flex-grow:1;flex-shrink:0}.navbar-start{justify-content:flex-start;margin-inline-end:auto}}:root{}.section{--bulma-section-padding:3rem 1.5rem;--bulma-section-padding-desktop:3rem 3rem;padding:var(--bulma-section-padding)}@media screen and (min-width:1024px){.section{padding:var(--bulma-section-padding-desktop)}}:root{}.mb-1{margin-bottom:.25rem!important}.mb-3{margin-bottom:.75rem!important}.p-1{padding:.25rem!important}.pt-3{padding-top:.75rem!important}.pt-4{padding-top:1rem!important}.pt-5{padding-top:1.5rem!important}.is-size-5{font-size:1.25rem!important}.has-text-justified{text-align:justify!important}.has-text-weight-bold{font-weight:700!important}.is-hidden{display:none!important}
binary file changed (absent → 5464 bytes)
binary file changed (absent → 6084 bytes)
@@ -0,0 +1,35 @@+<?xml version="1.0" encoding="UTF-8" standalone="no"?>+<!-- Created with Inkscape (http://www.inkscape.org/) -->++<svg+ width="32.522312"+ height="44.470715"+ viewBox="0 0 8.6048618 11.76621"+ version="1.1"+ id="svg1"+ xmlns="http://www.w3.org/2000/svg"+ xmlns:svg="http://www.w3.org/2000/svg">+ <defs+ id="defs1" />+ <g+ id="layer1"+ transform="translate(-2.100647,-0.47852103)">+ <path+ style="fill:#000000;stroke-width:0.0876456"+ d="m 5.7763916,0.47852376 c -0.028896,7.199e-5 -0.058313,0.0016 -0.08785,0.004651 L 2.6199951,1.8706868 C 2.3805968,1.9279778 2.2509491,2.1316261 2.100647,2.3073527 2.4740162,5.1754971 2.6098714,7.974667 3.2571655,10.778153 3.9049411,11.100276 5.6903297,12.026825 7.0083577,12.244731 7.902153,11.818738 8.837578,11.478727 9.660909,10.921814 10.124735,8.4385322 10.353565,5.912657 10.666532,3.4059937 10.739322,3.0838483 10.696122,2.7610673 10.650512,2.4386108 L 6.3484497,0.63820394 C 6.1658613,0.54724851 5.9786639,0.47801985 5.7763916,0.47852376 Z M 5.5335124,1.2991455 C 5.5852542,1.8622637 5.6729808,2.8860877 5.4485265,3.3269695 4.8170307,3.0281658 4.2442209,2.9857127 3.3641357,2.2706624 Z m 0.6046142,0.00982 c 0,-0.1826612 2.1690521,0.5703704 3.3233114,1.514118 L 6.865214,3.8953695 5.8806934,3.502468 C 5.9287499,2.7606597 5.9114555,2.0294543 6.1381266,1.308964 Z M 2.7595215,2.8928467 6.6187174,4.5392578 c -0.022214,2.833899 0.1565506,4.4708701 -0.040308,6.8977702 0,0 -0.6178673,-0.03141 -1.6781784,-0.423145 C 4.3827915,10.822711 3.5046956,10.124447 3.5046956,10.124447 3.2010632,8.2234072 2.7020154,3.6991522 2.7595215,2.8928467 Z M 10.0552,3.2897217 C 9.9428491,5.6693692 9.5113187,8.0859733 9.2030558,10.461377 8.6786335,10.831416 7.9112023,11.287021 7.1907756,11.320239 7.1735917,9.0349806 7.1562794,6.7495857 7.1390991,4.464327 Z"+ id="path7" />+ <g+ id="g2"+ transform="matrix(0.0038432,-0.00474116,0.0064534,0.00531595,2.3937052,7.0524224)"+ style="display:inline;fill:#000000">+ <path+ id="path1"+ style="fill:#000000;fill-rule:evenodd;stroke-width:16.1376"+ d="M 282.11652,24.412765 C 298.07579,95.581322 262.8793,266.47294 329.99348,237.91863 380.63723,216.37181 335.89612,135.24241 373.70561,115.62066 399.27191,102.35268 436.0036,11.581301 389.83963,39.572665 372.87766,49.857497 351.8287,105.84345 340.73495,55.416694 335.11988,29.893382 305.15181,20.507877 282.11652,24.412765 Z M 180.20098,129.44972 c -27.58331,32.43323 -54.57749,69.15923 -109.893211,52.78184 -38.198005,-11.30932 -69.7982862,54.61077 -17.862854,50.59629 33.810966,-2.6135 21.848539,15.2542 2.56362,29.54811 -25.108466,18.61031 -18.9073,87.414 18.203069,46.42178 C 121.3636,255.60891 288.99749,154.58397 217.46418,149.04753 c -14.9976,-1.16077 -22.27567,-18.4437 -37.2632,-19.59781 z m -13.90946,177.22375 c -5.04384,55.52785 94.51061,47.33907 86.38068,84.67131 -7.33499,33.68194 65.39054,89.28801 54.70058,37.83508 -4.56814,-21.98736 -20.32193,-57.33616 24.64531,-47.91523 35.32403,7.40062 80.23839,-58.85003 21.22512,-49.68619 -62.33316,-8.18223 -124.55943,-17.17304 -186.95169,-24.90497 z" />+ <path+ id="path4"+ style="fill:#000000;fill-rule:evenodd;stroke-width:16.1376"+ d="m 192.49348,12.738937 c -22.46692,13.10056 -4.97814,41.779302 -7.95103,64.578857 -3.85641,29.575276 -33.3467,6.559923 -54.42496,11.093667 -17.01094,3.658903 -49.74781,39.777519 -18.24603,42.484789 21.13315,1.81618 42.03181,4.32765 63.11042,6.93038 43.63082,5.38741 141.20509,58.89781 130.89246,16.16231 -4.27787,-17.72748 -3.48573,-41.30217 -21.93988,-50.95047 -21.61532,0.27915 -52.81221,-1.6127 -56.10374,-28.966173 -2.51359,-20.88859 1.73471,-53.833103 -22.99852,-60.752252 -4.0043,-1.120208 -8.24679,-1.217299 -12.33872,-0.581108 z M 434.47004,128.70964 c -60.28095,66.39738 -279.57062,186.79361 -189.955,190.1768 23.7376,0.89615 52.53057,17.35987 71.21281,2.68844 19.28069,-15.1414 27.50457,-53.25977 53.79734,-49.91879 24.89178,3.16296 50.8719,17.42651 74.67532,9.4891 19.57181,-6.52636 25.11445,-41.99959 3.1529,-48.73629 -12.7546,1.75508 -53.99442,-3.95634 -34.41184,-19.72211 17.68825,-14.24067 33.92005,-33.44456 27.07091,-60.3247 -1.99976,-7.84829 -3.52512,-15.80841 -5.54244,-23.65245 z M 153.6302,226.6823 c -11.9871,18.28437 -43.089,30.81565 -36.58903,55.07185 5.17772,19.32187 17.17066,44.94619 -0.31191,60.48521 -19.357188,17.20522 -46.310537,31.85018 -53.315865,56.84528 -6.875271,24.53105 38.643445,38.7984 44.527955,14.88974 3.52882,-14.33753 36.2814,-42.46282 35.12123,-13.67848 -0.97619,24.2197 29.57289,55.99709 48.03994,39.12107 18.59099,-16.98928 1.00268,-41.22008 -3.40594,-60.87284 C 176.34109,327.92353 164.98535,277.30298 153.6302,226.6823 Z" />+ </g>+ </g>+</svg>
binary file changed (absent → 2346 bytes)
binary file changed (absent → 2528 bytes)
@@ -0,0 +1,50 @@+<?xml version="1.0" encoding="UTF-8" standalone="no"?>+<!-- Created with Inkscape (http://www.inkscape.org/) -->++<svg+ version="1.1"+ id="svg1"+ width="470"+ height="470"+ viewBox="0 0 470 470"+ xml:space="preserve"+ sodipodi:docname="flake.svg"+ inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"+ xmlns="http://www.w3.org/2000/svg"+ xmlns:svg="http://www.w3.org/2000/svg"+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"+ xmlns:cc="http://creativecommons.org/ns#"><sodipodi:namedview+ id="namedview1"+ pagecolor="#4b412b"+ bordercolor="#c9b800"+ borderopacity="0.24705882"+ inkscape:showpageshadow="2"+ inkscape:pageopacity="0.0"+ inkscape:pagecheckerboard="0"+ inkscape:deskcolor="#d1d1d1"+ showgrid="false"+ inkscape:zoom="2.6574468"+ inkscape:cx="234.81185"+ inkscape:cy="235"+ inkscape:window-width="2560"+ inkscape:window-height="1523"+ inkscape:window-x="0"+ inkscape:window-y="0"+ inkscape:window-maximized="1"+ inkscape:current-layer="svg1" /><defs+ id="defs1" /><path+ id="path1"+ style="fill:#ffffff;fill-rule:evenodd;stroke:none;stroke-width:20.6022;stroke-dasharray:none;stroke-opacity:1"+ d="m 272.37474,24.789065 c 15.82163,71.168557 -19.07131,242.060175 47.46405,213.505865 50.20698,-21.54682 5.85173,-102.67622 43.33514,-122.29797 25.3458,-13.26799 61.7607,-104.039358 15.99487,-76.047995 C 362.35312,50.233797 341.4857,106.21975 330.48762,55.792994 324.92098,30.269682 295.21137,20.884177 272.37474,24.789065 Z M 171.33817,129.82602 c -27.34542,32.43323 -54.10679,69.15923 -108.945436,52.78184 -37.868569,-11.30932 -69.1963142,54.61077 -17.708797,50.59629 33.519366,-2.6135 21.660108,15.2542 2.541511,29.54811 -24.891921,18.61031 -18.744235,87.414 18.046077,46.42178 C 113.00823,255.98521 279.19637,154.96027 208.28,149.42383 c -14.86826,-1.16077 -22.08356,-18.4437 -36.94183,-19.59781 z m -13.7895,177.22375 c -5.00034,55.52785 93.69551,47.33907 85.6357,84.67131 -7.27173,33.68194 64.82658,89.28801 54.22881,37.83508 -4.52874,-21.98736 -20.14666,-57.33616 24.43276,-47.91523 35.01938,7.40062 79.54638,-58.85003 21.04207,-49.68619 -61.79558,-8.18223 -123.48518,-17.17304 -185.33934,-24.90497 z" /><path+ id="path4"+ style="fill:#ffffff;fill-rule:evenodd;stroke:none;stroke-width:17.9565;stroke-dasharray:none;stroke-opacity:1"+ d="m 145.96713,12.626792 c -19.85286,12.518291 2.78342,41.860937 4.04833,64.697591 1.64081,29.623387 -31.84718,5.62587 -51.906044,9.571303 -16.188257,3.184113 -41.969962,38.546564 -10.239714,42.184454 21.286428,2.4405 42.468818,5.57323 63.846488,8.80295 44.24984,6.68523 150.8685,63.32267 132.74954,20.05575 -7.51609,-17.94792 -11.08614,-41.62731 -31.16363,-51.86447 -21.37733,-0.34783 -52.65468,-3.15702 -60.97131,-30.754408 -6.35102,-21.07486 -8.22578,-54.074358 -34.02399,-61.750149 -4.17673,-1.242708 -8.40057,-1.463681 -12.33967,-0.943021 z M 407.28207,136.26165 c -47.49431,65.00441 -242.64983,179.67688 -153.18206,185.68409 23.69843,1.59121 55.28471,18.98133 71.09533,4.77361 16.31706,-14.66283 17.42774,-52.74863 44.11099,-48.62505 25.26144,3.90386 53.65266,19.0001 75.78437,11.7118 18.19728,-5.99265 17.13853,-41.49693 -5.87819,-48.90869 -12.32036,1.39373 -54.25968,-5.54774 -37.75867,-20.82955 14.90477,-13.80352 27.44871,-32.63951 15.6926,-59.86444 -3.43246,-7.94897 -6.41528,-15.99657 -9.86437,-23.94177 z M 146.96452,226.5994 c -8.50573,18.0349 -37.02426,29.72976 -26.09908,54.30638 8.70272,19.57712 25.32626,45.68899 10.86527,60.80388 -16.01162,16.73561 -40.026888,30.67622 -42.354017,55.60307 -2.283926,24.46407 45.478067,40.13224 46.894757,16.26513 0.84957,-14.31261 28.12359,-41.63798 32.29126,-12.7314 3.50676,24.32255 39.66316,57.16038 54.85314,40.72987 15.29193,-16.54078 -6.62127,-41.41428 -14.62267,-61.30171 C 188.1836,329.04955 167.57378,277.82453 146.96452,226.5994 Z" /><metadata+ id="metadata19"><rdf:RDF><cc:Work+ rdf:about=""><cc:license+ rdf:resource="http://creativecommons.org/publicdomain/zero/1.0/" /></cc:Work><cc:License+ rdf:about="http://creativecommons.org/publicdomain/zero/1.0/"><cc:permits+ rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits+ rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:permits+ rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /></cc:License></rdf:RDF></metadata></svg>
binary file changed (absent → 2067 bytes)
binary file changed (absent → 2230 bytes)
binary file changed (absent → 24576 bytes)
@@ -0,0 +1,31 @@+<?xml version="1.0" encoding="utf-8"?> + +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->+<svg version="1.1" + id="svg2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" sodipodi:docname="github.svg" inkscape:version="0.48.4 r9939" + xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px" height="800px" + viewBox="0 0 1200 1200" enable-background="new 0 0 1200 1200" xml:space="preserve"> +<path id="path10015" inkscape:connector-curvature="0" d="M600,0C268.629,0,0,268.629,0,600s268.629,600,600,600 + s600-268.629,600-600S931.371,0,600,0z M600,65.332c295.289,0,534.668,239.379,534.668,534.668S895.289,1134.668,600,1134.668 + S65.332,895.289,65.332,600S304.711,65.332,600,65.332z M334.644,221.924c-15.228,44.26-20.507,92.298-6.885,134.253 + c-16.457,18.264-29.235,39.243-38.379,62.988c-24.371,77.026-18.418,175.425,34.937,234.961 + c18.743,20.548,44.172,37.441,76.172,50.684c31.999,13.241,73.127,21.688,123.413,25.343 + c-33.751,15.771-57.543,25.406-65.846,60.277c-37.646,25.156-83.427,19.153-116.602-8.203 + c-26.217-19.143-38.094-53.164-67.163-65.771c-1.828-1.826-7.539-3.188-17.139-4.103c-9.6-0.913-17.146,1.811-22.632,8.203 + c-2.743,2.739-2.541,5.666,0.659,8.862c22.006,17.897,43.187,36.867,55.591,59.619c11.886,24.657,24.188,42.711,36.987,54.126 + c35.507,23.993,83.126,36.476,124.806,21.24c-4.86,29.329,8.252,75.13-1.393,101.367c-3.658,7.306-8.89,13.71-15.747,19.188 + c-6.111,5.962-25.06,13.42-21.24,22.56c1.828,4.108,8.219,6.631,19.189,7.544c24.151-0.616,48.193-10.382,62.402-29.443 + c5.028-6.85,7.544-15.774,7.544-26.733V846.607c0-12.785,2.717-21.913,8.203-27.394c5.484-5.479,11.471-9.16,17.87-10.985v147.948 + c0,12.785-1.155,23.753-3.441,32.886c-2.286,9.134-4.324,15.536-6.152,19.189c-4.197,7.039-12.221,13.535-12.378,21.899 + c0,2.738,1.635,4.305,4.834,4.761c24.021-1.042,51.889-15.133,61.67-34.938c7.771-16.438,11.646-33.736,11.646-52.002V802.734 + h30.176v145.238c0,18.266,4.149,35.563,12.378,52.002c8.229,16.439,21.962,27.002,41.162,31.567 + c10.057,2.74,16.85,3.826,20.508,3.369c3.657-0.457,5.291-2.021,4.834-4.761c-2.202-8.299-6.791-15.397-11.646-21.899 + c-6.4-8.219-9.596-25.591-9.596-52.075V808.229c6.4,1.825,12.588,5.507,18.53,10.985c5.942,5.479,8.862,14.607,8.862,27.394v112.279 + c0,10.959,2.515,19.885,7.544,26.733c15.283,19.437,38.314,29.297,62.401,29.443c10.973-0.913,17.361-3.436,19.189-7.544 + c1.828-4.109,0.47-7.516-4.102-10.255s-10.281-6.824-17.14-12.305c-6.857-5.479-12.09-11.884-15.747-19.188 + c-4.104-47.109-0.104-97.608-3.441-145.239c-6.589-51.979-26.597-69.079-66.504-88.989c47.543-3.653,86.633-12.376,117.261-26.074 + c91.558-44.484,116.875-113.258,117.261-205.443c-1.444-59.33-18.57-111.086-57.642-150.732c5.484-21.004,7.12-42.867,4.834-65.698 + c-2.286-22.831-7.115-43.405-14.43-61.67c-33.824,1.826-62.174,8.635-85.033,20.508c-22.857,11.873-39.31,21.957-49.365,30.176 + c-81.016-18.393-167.743-18.503-245.436,2.71C430.68,242.803,381.41,224.688,334.644,221.924L334.644,221.924z"/> +</svg>
binary file changed (absent → 1683 bytes)
binary file changed (absent → 1774 bytes)
@@ -0,0 +1,5 @@+User-agent: *+Allow: /index.html$+Allow: /$+Disallow: /+Sitemap: https://pieceofflakenixrepository.org/sitemap.xml
@@ -0,0 +1,7 @@+<?xml version="1.0" encoding="UTF-8"?>+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">+ <url>+ <loc>https://pieceofflakenixrepository.org/index.html</loc>+ <lastmod>2026-03-14</lastmod>+ </url>+</urlset>
binary file changed (absent → 33349 bytes)
@@ -0,0 +1,132 @@+/* A Piece of Flake */++@property --navy-logo-color {+ syntax: "<color>";+ inherits: false;+ initial-value: #5277c3;+}++@property --blue-logo-color {+ syntax: "<color>";+ inherits: false;+ initial-value: #7ebae4;+}++.ani-run .ani {+ position: fixed;+ z-index: 111;+ display: block;+ animation-play-state: running;+ animation-delay: 0s;+ animation-direction: normal;+ animation-iteration-count: infinite;+ animation-timing-function: cubic-bezier(0, 0.38, 1, 1);+}++.ani {+ display: none;+ width: 7vh;+ height: 7vh;+ animation-play-state: paused;+}++@keyframes fall1 {+ from {+ transform: translateY(-6vh) translateX(-6vw);+ opacity: 1;+ }+ to {+ transform: translateY(100vh) translateX(10vw) rotate(360deg);+ opacity: 0.2;+ }+}+@keyframes fall2 {+ from {+ transform: translateY(-6vh) translateX(16vw);+ opacity: 1;+ }+ to {+ transform: translateY(100vh) translateX(30vw) rotate(360deg);+ opacity: 0.3;+ }+}+@keyframes fall3 {+ from {+ transform: translateY(-6vh) translateX(44vw);+ opacity: 1;+ }+ to {+ transform: translateY(100vh) translateX(70vw) rotate(-360deg);+ opacity: 0.2;+ }+}+@keyframes fall4 {+ from {+ transform: translateY(-6vh) translateX(90vw);+ opacity: 1;+ }+ to {+ transform: translateY(100vh) translateX(70vw) rotate(-360deg);+ opacity: 0.4;+ }+}++.fall1 { animation-duration: 3s; animation-name: fall1; }+.fall2 { animation-duration: 2.2s; animation-name: fall2; }+.fall3 { animation-duration: 3.4s; animation-name: fall3; }+.fall4 { animation-duration: 2.7s; animation-name: fall4; animation-delay: 0.3s; }++.navbar .navbar-brand a.navbar-item {+ color: var(--navy-logo-color);+}++button.is-black {+ font-weight: bold;+}++body {+ background: radial-gradient(circle, var(--blue-logo-color) 0%, var(--blue-logo-color) 80%, var(--navy-logo-color) 100%);+ color: #171717;+ min-height: 100vh;+ overflow: scroll !important;+ --bulma-body-overflow-x: scroll !important;+}++body pre.error {+ white-space: pre-wrap;+}++body .table {+ background: transparent;+}++body .table.is-bordered td {+ border-width: 2px;+}++body .table td {+ border-color: hsl(221.1, 17.8%, 61.4%);+}++.input.is-success {+ border-width: 4px !important;+ box-shadow: 20px 10px 20px green !important;+ background-color: var(--bulma-success-25-invert);+}++.input.is-danger {+ border-width: 4px !important;+ box-shadow: 10px 5px 5px var(--bulma-danger) !important;+ background-color: var(--bulma-danger-25-invert);+}++img.github {+ width: 8vh;+ opacity: 0.8;+ padding-top: 5vh;+}++.content p {+ overflow-wrap: break-word;+ text-wrap: pretty;+}
binary file changed (absent → 799 bytes)
binary file changed (absent → 894 bytes)
@@ -0,0 +1,4 @@+# a-piece-of-flake changelog++## Version 0.0.1 2026-02-26+ * init
@@ -0,0 +1,57 @@+{-# LANGUAGE TypeFamilies #-}+module PieceOfFlake.Acid where++import Data.Acid+ ( query,+ update,+ openLocalStateFrom,+ makeAcidic,+ AcidState,+ Query,+ Update )+import PieceOfFlake.CmdArgs ( AcidFlakesPath )+import Data.SafeCopy ( deriveSafeCopy, base )+import PieceOfFlake.Flake ( FlakeUrl, Flake )+import PieceOfFlake.Prelude+import PieceOfFlake.Stm ( TQueue, readTQueue, peekTQueue )+import UnliftIO.Retry+ ( fibonacciBackoff, limitRetries, recoverAll )+++newtype AcidFlakes = AcidFlakes { unAcidFlakes :: [(FlakeUrl, Flake)] }++$(deriveSafeCopy 0 'base ''AcidFlakes)++type Message = (FlakeUrl, Flake)+-- Transactions are defined to run in either the 'Update' monad+-- or the 'Query' monad.+addMessage :: Message -> Update AcidFlakes ()+addMessage msg+ = do AcidFlakes messages <- get+ put $ AcidFlakes (msg:messages)++viewMessages :: Query AcidFlakes [Message]+viewMessages = unAcidFlakes <$> ask++-- This will define @ViewMessage@ and @AddMessage@ for us.+$(makeAcidic ''AcidFlakes ['addMessage, 'viewMessages])++openFlakeDb :: MonadIO m => Tagged AcidFlakesPath FilePath -> m (AcidState AcidFlakes)+openFlakeDb (Tagged dbLocation) =+ liftIO $ openLocalStateFrom dbLocation (AcidFlakes [])++updateDb :: PoF m => AcidState AcidFlakes -> Message -> m ()+updateDb db m = do+ liftIO $ update db (AddMessage m)+ $(logInfo) $ "Persisted flake " <> show (fst m)++loadFromDb :: MonadIO m => AcidState AcidFlakes -> m [(FlakeUrl, Flake)]+loadFromDb db = liftIO (query db ViewMessages)++runPersistQueue :: PoF m => AcidState AcidFlakes -> TQueue (FlakeUrl, Flake) -> m ()+runPersistQueue db q = finally go (void $ atomically $ readTQueue q)+ where+ go =+ recoverAll+ (fibonacciBackoff 100_000 <> limitRetries 3)+ (\_ -> updateDb db =<< atomically (peekTQueue q))
@@ -0,0 +1,25 @@+module PieceOfFlake.Aeson+ ( module Data.Aeson+ , Some (..)+ , someToList+ ) where++import Data.Aeson+ ( Value(Array, Number, String, Bool, Object, Null),+ FromJSON(parseJSON) )+import PieceOfFlake.Prelude ( Eq, Show, (<$>) )++data Some x = Atom x | More [x] deriving (Show, Eq)++instance FromJSON x => FromJSON (Some x) where+ parseJSON n@Number {} = Atom <$> parseJSON n+ parseJSON n@String {} = Atom <$> parseJSON n+ parseJSON n@Bool {} = Atom <$> parseJSON n+ parseJSON n@Object {} = Atom <$> parseJSON n+ parseJSON n@Null {} = Atom <$> parseJSON n+ parseJSON n@Array {} = More <$> parseJSON n++someToList :: Some a -> [a]+someToList = \case+ Atom x -> [x]+ More xs -> xs
@@ -0,0 +1,354 @@+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE TemplateHaskell #-}+module PieceOfFlake.CmdArgs where++import Data.Char (isDigit)+import Data.Either.Combinators ( mapLeft )+import GHC.TypeLits (KnownSymbol)+import Network.HostName ( getHostName )+import Options.Applicative+import PieceOfFlake.Flake ( FetcherId(..) )+import PieceOfFlake.Prelude+import PieceOfFlake.Prelude qualified as P+import PieceOfFlake.Req ( DynamicUrl(UrlHttp), http, port, parseUrl )+import PieceOfFlake.WebService+ ( FetcherHeartbeatPeriod, FetcherSecret, NoSubmitionHeartbeatSec )+import Text.Show ( Show(show) )++data HttpPort+data Cert+data CertKey+data AcidFlakesPath+data StaticCacheSeconds+data RawNixCacheOutput+data BaseUrl+data ResubmitPeriod+type IndexQueryCacheSize = "index-query-cache-size"+type BadFlakeMaxAge = "bad-flake-max-age"++newtype RingBufferSize = RingBufferSize (Refined (FromTo 1 12) Int)++instance Show RingBufferSize where+ show = P.show . unrefine . coerce+++data WsCmdArgs+ = WsCmdArgs+ { httpPortToListen :: Tagged HttpPort Int+ , certFile :: Maybe (Tagged Cert FilePath)+ , keyFile :: Maybe (Tagged CertKey FilePath)+ , acidFlakes :: Tagged AcidFlakesPath FilePath+ , staticCache :: Tagged StaticCacheSeconds Word32+ , baseUrl :: Tagged BaseUrl Text+ , fetcherSecretPath :: Tagged FetcherSecret FilePath+ , noSubmitionHeartbeat :: Tagged NoSubmitionHeartbeatSec Second+ , allowResubmitBadFlakeIn :: Tagged ResubmitPeriod NominalDiffTime+ , logLevel :: LogLevel+ , indexQueryCacheSize :: Tagged IndexQueryCacheSize Word+ , ringBufferSize :: RingBufferSize+ , badFlakeMaxAge :: Tagged BadFlakeMaxAge Second+ , allowResubmitIndexedFlakeIn :: Tagged ResubmitPeriod NominalDiffTime+ , fetcherHeartbeatPeriod :: Tagged FetcherHeartbeatPeriod Second+ }+ deriving Show++type RawNixCacheMaxAge = "nix-cache-max-age"+type RawNixCacheErrorMaxAge = "nix-cache-err-max-age"+type LooseFlakes = "loose-flake"+type NixGCDiskCriticalCapacity = "nix-gc-used"+data FetcherCmdArgs+ = FetcherCmdArgs+ { webServiceUrl :: DynamicUrl+ , rawNixCache :: Tagged RawNixCacheOutput (Maybe FilePath)+ , fetcherId :: FetcherId+ , fetcherSecretPath :: Tagged FetcherSecret FilePath+ , rawNixCacheMaxAge :: Tagged RawNixCacheMaxAge NominalDiffTime+ , rawNixCacheErrMaxAge :: Tagged RawNixCacheErrorMaxAge NominalDiffTime+ , logLevel :: LogLevel+ , looseFlakes :: Tagged LooseFlakes Bool+ , runNixGCIfUsedMoreThan :: Tagged NixGCDiskCriticalCapacity Percent+ }+ deriving (Show)++data SubmitListOfFlakesArgs+ = SubmitListOfFlakesArgs+ { webServiceUrl :: DynamicUrl+ , logLevel :: LogLevel+ , indexTimeoutIn :: Tagged ResubmitPeriod NominalDiffTime+ }+ deriving (Show)++data CmdArgs+ = WebService WsCmdArgs+ | FetcherJob FetcherCmdArgs+ | SubmitListOfFlakes SubmitListOfFlakesArgs+ | PieceOfFlakeVersion+ deriving (Show)++execWithArgs :: MonadIO m => (CmdArgs -> m a) -> [String] -> m a+execWithArgs a args = a =<< liftIO (handleParseResult $ execParserPure defaultPrefs (info (cmdp <**> helper) phelp) args)+ where+ serviceP = WebService <$> (WsCmdArgs <$> portOption <*> certO <*>+ certKeyO <*> acidOption <*> cacheSecondsO <*>+ baseUrlO <*> fetcherSecretPathO <*> noSubmitionHeartbeatO <*>+ allowResubmitBadFlakeInO <*> logLevelO <*>+ indexQueryCacheSizeO <*> ringBufferSizeO <*> badFlakeMaxAgeO <*>+ allowResubmitIndexedFlakeInO <*> fetcherHeartbeatPeriodO)+ fetcherP = FetcherJob <$> (FetcherCmdArgs <$> urlOption <*> rawNixCacheO <*>+ customFetcherIdO <*> fetcherSecretPathO <*>+ rawNixCacheMaxAgeO @RawNixCacheMaxAge (7 * 24 * 3600) <*>+ rawNixCacheMaxAgeO @RawNixCacheErrorMaxAge 800 <*>+ logLevelO <*> looseFlakesO <*> nixGcDiskUsedO)+ submitListP = SubmitListOfFlakes <$>+ (SubmitListOfFlakesArgs <$> urlOption <*> logLevelO <*> allowResubmitBadFlakeInO)+ cmdp =+ hsubparser+ ( command "web" (infoP serviceP "launch web service")+ <> command "fetcher" (infoP fetcherP "launch fetcher job")+ <> command "submit-list" (infoP submitListP "submit list of flakes read for stdin - an url per line")+ <> command "version" (infoP (pure PieceOfFlakeVersion) "print program version"))++ infoP p h = info p (progDesc h <> fullDesc)+ phelp =+ progDesc+ "Nix Flake repository"++defaultPort :: Int+defaultPort = 3003++newtype Percent = Percent Word8 deriving newtype (Eq, Ord)+instance Show Percent where+ show (Percent p) = P.show p <> "%"++parsePercent :: String -> Maybe Percent+parsePercent s =+ case span isDigit s of+ (digits@(_:_), "%") -> Percent <$> readMaybe digits+ _bad -> Nothing++nixGcDiskUsedO :: Parser (Tagged NixGCDiskCriticalCapacity Percent)+nixGcDiskUsedO = Tagged <$>+ option (maybeReader parsePercent)+ ( long (symbolVal $ Proxy @NixGCDiskCriticalCapacity)+ <> showDefault+ <> value (Percent 90)+ <> help "percent of root volume usage when nix GC is triggerred before fetching a flake"+ )++allowResubmitBadFlakeInO :: Parser (Tagged ResubmitPeriod NominalDiffTime)+allowResubmitBadFlakeInO = Tagged <$>+ option auto+ ( long "resubmit-bad-flake-interval"+ <> showDefault+ <> value 600+ <> help "how soon bad flake can be resubmitted"+ )++allowResubmitIndexedFlakeInO :: Parser (Tagged ResubmitPeriod NominalDiffTime)+allowResubmitIndexedFlakeInO = Tagged <$>+ option auto+ ( long "resubmit-indexed-flake-interval"+ <> showDefault+ <> value (toNominal (2 :: Hour))+ <> help "how soon an indexed flake can be resubmitted"+ )++looseFlakesO :: Parser (Tagged LooseFlakes Bool)+looseFlakesO = Tagged <$>+ option auto+ ( long (symbolVal $ Proxy @LooseFlakes)+ <> showDefault+ <> value False+ <> help "loose fetched flake - debugging web service response"+ )++logLevelO :: Parser LogLevel+logLevelO =+ option auto+ ( long "log-level"+ <> short 'l'+ <> showDefault+ <> value LevelDebug+ <> help "app log level"+ <> metavar "LOG"+ )++rawNixCacheMaxAgeO :: forall a. KnownSymbol a => NominalDiffTime -> Parser (Tagged a NominalDiffTime)+rawNixCacheMaxAgeO defVal =+ Tagged <$>+ option auto+ ( long (symbolVal $ Proxy @a)+ <> showDefault+ <> value defVal+ <> help "max age of cached entity"+ )++badFlakeMaxAgeO :: Parser (Tagged BadFlakeMaxAge Second)+badFlakeMaxAgeO = Tagged <$>+ option auto+ ( long (symbolVal $ Proxy @BadFlakeMaxAge)+ <> showDefault+ <> value (8 * 3600)+ <> help "bad flake max age"+ )++fetcherHeartbeatPeriodO :: Parser (Tagged FetcherHeartbeatPeriod Second)+fetcherHeartbeatPeriodO = Tagged <$>+ option auto+ ( long "fetcher-heartbeat"+ <> showDefault+ <> value 10+ <> help "period of heartbeats from fetcher to make WS keep flake association with fetcher"+ )++noSubmitionHeartbeatO :: Parser (Tagged NoSubmitionHeartbeatSec Second)+noSubmitionHeartbeatO = Tagged <$>+ option auto+ ( long "heartbeat"+ <> short 'b'+ <> showDefault+ <> value 600+ <> help "period for putting empty submition request into fetcher queue (in seconds) when queue is empty"+ <> metavar "HEARTBEAT"+ )++ringBufferSizeO :: Parser RingBufferSize+ringBufferSizeO =+ option (eitherReader (mapLeft toString . readEither >=> fmap RingBufferSize . mapLeft P.show . refine))+ ( long "ring-buffer"+ <> showDefault+ <> value (RingBufferSize $$(refineTH 3))+ <> help "ring buffer size that is used for mean values"+ )++indexQueryCacheSizeO :: Parser (Tagged IndexQueryCacheSize Word)+indexQueryCacheSizeO = Tagged <$>+ option auto+ ( long (symbolVal $ Proxy @IndexQueryCacheSize)+ <> showDefault+ <> value 100+ <> help "index query cache size"+ )++cacheSecondsO :: Parser (Tagged StaticCacheSeconds Word32)+cacheSecondsO = Tagged <$>+ option auto+ ( long "static-cache"+ <> showDefault+ <> value 1+ <> help "cache duration for static content (used in HTTP header)"+ <> metavar "STATIC_CACHE"+ )++rawNixCacheO :: Parser (Tagged RawNixCacheOutput (Maybe FilePath))+rawNixCacheO = Tagged <$>+ option (eitherReader parse )+ ( long "raw-nix-cache-output"+ <> short 'c'+ <> showDefault+ <> value (pure "fetcher-raw-nix-cache")+ <> help "path to fetcher cache of nix raw output"+ <> metavar "RAW_NIX_CACHE"+ )+ where+ parse = \case+ "" -> pure Nothing+ "null" -> pure Nothing+ "-" -> pure Nothing+ o -> pure $ Just o++customFetcherIdO :: Parser FetcherId+customFetcherIdO = FetcherId . toText <$>+ option str+ ( long "fetcher-id"+ <> short 'i'+ <> showDefault+ <> value (unsafePerformIO getHostName)+ <> help "fetcher id"+ <> metavar "FID"+ )++fetcherSecretPathO :: Parser (Tagged FetcherSecret FilePath)+fetcherSecretPathO = Tagged <$>+ option str+ ( long "fetcher-secret"+ <> short 's'+ <> showDefault+ <> value ".fetcher-secret"+ <> help "path to file with secret for fetcher authentication on web service"+ <> metavar "SECRET"+ )++acidOption :: Parser (Tagged AcidFlakesPath FilePath)+acidOption = Tagged <$>+ option str+ ( long "acid"+ <> short 'a'+ <> showDefault+ <> value "acid-flakes/"+ <> help "path do ACID flake store"+ <> metavar "ACID"+ )++baseUrlO :: Parser (Tagged BaseUrl Text)+baseUrlO = Tagged <$>+ option str+ ( long "base-url"+ <> short 'u'+ <> showDefault+ <> value ("http://localhost:" <> P.show defaultPort)+ <> help "base web service url for HTML links"+ <> metavar "URL"+ )++urlOption :: Parser DynamicUrl+urlOption =+ option (eitherReader (mapLeft toString . parseUrl . toText))+ ( long "url"+ <> short 'u'+ <> showDefault+ <> value (UrlHttp (http "localhost") (port defaultPort))+ <> help "web service url for fetching flake submition requests and uploading flake meta back"+ <> metavar "URL"+ )++portOption :: Parser (Tagged HttpPort Int)+portOption = Tagged <$>+ option auto+ ( long "port"+ <> short 'p'+ <> showDefault+ <> value defaultPort+ <> help "HTTP(S) port to listen"+ <> metavar "PORT"+ )++emptyToNothing :: FilePath -> Maybe FilePath+emptyToNothing "" = Nothing+emptyToNothing s = Just s++zeroToNothing :: Int -> Maybe Int+zeroToNothing s | s <= 0 = Nothing+ | otherwise = Just s++pured :: (Applicative g, Applicative f) => g a -> g (f a)+pured = fmap pure++certO :: Parser (Maybe (Tagged Cert FilePath))+certO = pured . emptyToNothing <$>+ strOption+ ( long "certificate"+ <> short 'c'+ <> value ""+ <> help "path to SSL certificate file (./certificate.pem)"+ <> metavar "CERT"+ )+certKeyO :: Parser (Maybe (Tagged CertKey FilePath))+certKeyO = pured . emptyToNothing <$>+ strOption+ ( long "key"+ <> short 'k'+ <> value ""+ <> help "path to key file of SSL certificate (./key.pem)"+ <> metavar "KEY"+ )
@@ -0,0 +1,102 @@+module PieceOfFlake.CmdRun where++import Data.Version (showVersion)+import Data.ByteString.Char8 qualified as S8+import Paths_a_piece_of_flake ( version )+import PieceOfFlake.Acid ( loadFromDb, openFlakeDb, runPersistQueue )+import PieceOfFlake.CmdArgs+ ( WsCmdArgs(staticCache, indexQueryCacheSize, acidFlakes,+ ringBufferSize, logLevel, fetcherSecretPath, noSubmitionHeartbeat, fetcherHeartbeatPeriod),+ CmdArgs(..),+ FetcherCmdArgs(fetcherSecretPath, FetcherCmdArgs, logLevel),+ SubmitListOfFlakesArgs(logLevel) )+import PieceOfFlake.Fetcher ( runFetcher )+import PieceOfFlake.Flake.Repo+ ( FlakeRepo(flakeIndex, wsArgs, acidFlakes, acidQueue, repoStats,+ flakes),+ mkFlakeRepo,+ removeOldBadFlakes,+ sendEmptyFlakeSubmition,+ resubmitFlakesFetchingByZombie )+import PieceOfFlake.Http ( runWebService )+import PieceOfFlake.Index+ ( mkFlakeIndex, loadIndexFromScratch, consumeIndexQueue )+import PieceOfFlake.Page ( Ypp(Ypp) )+import PieceOfFlake.Prelude+import PieceOfFlake.Stats ( mkRepoStats )+import PieceOfFlake.SubmitList ( runSubmitList )+import PieceOfFlake.WebService+ ( FetcherSecret(..), NoSubmitionHeartbeatSec )+import StmContainers.Map ( newIO )+import UnliftIO.Concurrent ( forkFinally )+import UnliftIO.Retry ( fibonacciBackoff, recoverAll )++initRepo :: PoF m => FetcherSecret -> WsCmdArgs -> m FlakeRepo+initRepo fsec wsa = do+ flakesMap <- liftIO newIO+ fi <- mkFlakeIndex wsa.indexQueryCacheSize+ acidFlakeStorage <- openFlakeDb wsa.acidFlakes+ rs <- mkRepoStats wsa.ringBufferSize+ loadIndexFromScratch rs fi flakesMap . reverse =<< loadFromDb acidFlakeStorage+ mkFlakeRepo fsec wsa fi flakesMap acidFlakeStorage rs++launchBackgroundThreads :: PoF m =>+ Tagged NoSubmitionHeartbeatSec Second -> FlakeRepo -> m ()+launchBackgroundThreads period fr = do+ forkForever "Lost flake resubmition" $ do+ resubmitFlakesFetchingByZombie fr+ threadDelay . (* 3) $ untag fr.wsArgs.fetcherHeartbeatPeriod+ forkForever "Flake persistence" $ do+ recoverAll+ (fibonacciBackoff 10000)+ (\_ -> runPersistQueue fr.acidFlakes fr.acidQueue)+ forkForever "Flake text search indexer"+ $ consumeIndexQueue fr.repoStats fr.flakes fr.flakeIndex+ forkForever "Empty Submition" $ do+ threadDelay $ untag period+ sendEmptyFlakeSubmition fr+ forkForever "Old Bad Flake Collector" $ removeOldBadFlakes fr+ where+ forkForever thrName f = do+ tid <- forkFinally+ (forever f)+ (\case+ Left e -> $(logError) $ thrName <> " thread ended: " <> show e+ Right () -> $(logInfo) $ thrName <> " thread ended without errors")+ $(logInfo) $ thrName <> " thread is forked with tid: " <> show tid++-- commented lines below are excuted via: @ghciwatch --enable-eval@+-- $> import PieceOfFlake.CmdArgs+-- $> execWithArgs runCmd . (:[]) =<< (fromMaybe "web" <$> PieceOfFlake.Prelude.lookupEnv "E")+runCmd :: CmdArgs -> IO ()+runCmd = \case+ WebService ws ->+ withLogs ws.logLevel $ do+ $(logInfo) $ "Start WebService " <> show ws+ fr <- (`initRepo` ws) =<< loadFetcherSecret ws.fetcherSecretPath+ launchBackgroundThreads ws.noSubmitionHeartbeat fr+ runWebService ws $ Ypp fr ws.staticCache+ FetcherJob fa@FetcherCmdArgs {} ->+ withLogs fa.logLevel $ do+ $(logInfo) $ "Start Fetcher " <> show fa+ runFetcher fa =<< loadFetcherSecret fa.fetcherSecretPath+ SubmitListOfFlakes sla ->+ withLogs sla.logLevel $ runSubmitList sla+ PieceOfFlakeVersion ->+ putStrLn $ "Version " <> showVersion version++withLogs :: MonadIO m => LogLevel -> LoggingT m a -> m a+withLogs minLogL m =+ runFlushingStdoutLoggingT $ filterLogger (\_ l -> l >= minLogL) m+ where+ runFlushingStdoutLoggingT = (`runLoggingT` flushingOutput stdout)+ where+ -- without explicit flushing - syslog does to get logs+ flushingOutput h loc src level msg = S8.hPutStr h ls >> hFlush h+ where+ ls = defaultLogStrBS loc src level msg+ defaultLogStrBS a b c d = fromLogStr $ defaultLogStr a b c d+++loadFetcherSecret :: MonadIO m => Tagged FetcherSecret FilePath -> m FetcherSecret+loadFetcherSecret a = readFileTxt (untag a) <&> FetcherSecret
@@ -0,0 +1,413 @@+module PieceOfFlake.Fetcher where++import Crypto.Hash.SHA1 (hashlazy)+import Data.Aeson qualified as A+import Data.Aeson+ ( FromJSON(parseJSON),+ eitherDecodeStrict,+ genericParseJSON,+ defaultOptions,+ Options(fieldLabelModifier) )+import Data.Char ( toLower )+import Data.ByteArray.Encoding (Base(Base16), convertToBase)+import Data.ByteString.Char8 qualified as BS8+import Data.ByteString.Lazy qualified as LBS+import Data.Map.Strict qualified as M+import Data.Text qualified as T+import PieceOfFlake.Aeson ( Some, someToList )+import PieceOfFlake.CmdArgs+import PieceOfFlake.Flake+ ( FlakeUrl(..),+ Architecture(..),+ PackageName(..),+ PackageInfo(..),+ MetaFlake(..) )+import PieceOfFlake.Flake.Repo ( FetcherReq(FetcherReq) )+import PieceOfFlake.Prelude+import PieceOfFlake.Req+ ( DynamicUrl,+ setResponseTimeout,+ (/:),+ defaultHttpConfig,+ isStatusCodeException,+ jsonResponse,+ responseBody,+ responseStatusCode,+ runReq,+ GET(GET),+ HttpConfig(httpConfigRetryJudge, httpConfigRetryJudgeException),+ HttpException(VanillaHttpException),+ NoReqBody(NoReqBody),+ POST(POST),+ ReqBodyJson(ReqBodyJson),+ dynReq,+ dynReq' )++import PieceOfFlake.WebService+ ( FetcherAutoConfig(httpMinTimeout, heartbeatPeriod),+ FetcherHeartbeat(fetcherSecret, FetcherHeartbeat, fetcherId,+ workingOn),+ FetcherSecret,+ Period(Period, unPeriod) )+import System.Exit ( ExitCode(ExitFailure, ExitSuccess) )+import System.Process ( callProcess, readProcess, readProcessWithExitCode )+import Text.Regex.TDFA ( AllTextSubmatches(getAllTextSubmatches), (=~) )+import UnliftIO.Async ( withAsync )+import UnliftIO.Retry+ ( capDelay,+ fibonacciBackoff,+ limitRetries,+ recoverAll,+ recovering )+import UnliftIO.Directory+ ( doesFileExist,+ createDirectoryIfMissing,+ removeFile )+import System.FilePath ( (</>) )+++showDigest :: ByteString -> String+showDigest = BS8.unpack . convertToBase Base16++readJsonDirect :: (FromJSON a, MonadIO m) => Text -> [Text] -> m a+readJsonDirect prg prgArgs = do+ prgOut <- liftIO (readProcess (toString prg) (toString <$> prgArgs) "")+ case eitherDecodeStrict $ encodeUtf8 prgOut of+ Right x -> pure x+ Left e ->+ throwIO . stringException $+ "Failed to parse output as JSON:\n" <> prgOut <> "\nFrom: " <>+ toString (T.intercalate " " prgArgs) <> "\nError: " <> e++newtype ReadJsonCached = ReadJsonCached FilePath++{- hash path cut 2 chars join with </> mkdir -p+ by that path create files out.json and cmd.sh+-}++isFileOlderThan :: (MonadIO m, ClockMonad m) => Tagged a FilePath -> Tagged a NominalDiffTime -> m Bool+isFileOlderThan (Tagged fp) (Tagged age) = do+ getModificationTime fp >>= (`doAfter` \mt -> do now <- getTimeAfter mt; pure $ now `diffUTCTime` mt > age)++readJsonCached :: (FromJSON a, FetcherM m) => FilePath -> Text -> [Text] -> m a+readJsonCached cacheDir prg prgArgs = do+ let cmd = LBS.intercalate " " (encodeUtf8 <$> (prg : prgArgs))+ (cmdH2, cmdHashRest) = splitAt 2 $ showDigest $ hashlazy cmd+ callDir = cacheDir </> cmdH2 </> cmdHashRest+ exOut = Tagged @RawNixCacheErrorMaxAge $ callDir </> "err.txt"+ outJson = Tagged @RawNixCacheMaxAge $ callDir </> "out.json"+ cmdFile = callDir </> "cmd.sh"+ doDirect = do+ $(logDebug) $ "Direct read " <> show cmd+ createDirectoryIfMissing True callDir+ prgOut <- liftIO $ do+ readProcessWithExitCode (toString prg) (toString <$> prgArgs) "" >>= \case+ (ExitSuccess, out, _err) -> pure out+ (ExitFailure ec, _, err) -> do+ writeFileBS (untag exOut) $ encodeUtf8 err+ throwIO . stringException $ "Command [" <> show cmd <> "] exited with: " <>+ show ec <> " and output: " <> err+ let prgOutBs = encodeUtf8 prgOut+ writeFileBS (untag outJson) prgOutBs+ writeFileLBS cmdFile cmd+ case eitherDecodeStrict prgOutBs of+ Right x -> do+ pure x+ Left e ->+ throwIO . stringException $+ "Failed to parse output as JSON:\n" <> prgOut <> "\nFrom: " <>+ toString (T.intercalate " " prgArgs) <> "\nError: " <> e++ $(logInfo) $ "readJsonCached " <> show cmd+ ifM (doesFileExist $ untag exOut)+ (do+ $(logDebug) $ "Ex cache exist for " <> show cmd+ ifM (isFileOlderThan exOut . rawNixCacheErrMaxAge =<< asks fetcherArgs)+ (do $(logDebug) $ "Ex cache is expired for " <> show cmd+ removeFile $ untag exOut+ doDirect)+ (throwIO . stringException . show =<< readFileBS (untag exOut))+ )+ (ifM (doesFileExist $ untag outJson)+ (do+ $(logDebug) $ "Out cache exist for " <> show cmd+ ifM (isFileOlderThan outJson . rawNixCacheMaxAge =<< asks fetcherArgs)+ doDirect+ (do+ $(logDebug) $ "Read cache from: " <> show outJson+ jsonLbs <- readFileBS $ untag outJson+ case eitherDecodeStrict jsonLbs of+ Right x -> pure x+ Left e ->+ throwIO . stringException $+ "Failed to parse output as JSON:\n" <> show jsonLbs <> "\nFrom: " <>+ toString (T.intercalate " " prgArgs) <> "\nError: " <> e+ )+ )+ doDirect+ )++data FetcherConf+ = FetcherConf+ { fetUrl :: DynamicUrl+ , arch :: Architecture+ , fetcherSecret :: FetcherSecret+ , fetcherArgs :: FetcherCmdArgs+ , flakeLoser :: Maybe (FlakeUrl, Either Text MetaFlake) -> Maybe (FlakeUrl, Either Text MetaFlake)+ , confFromWs :: FetcherAutoConfig+ }++type FetcherM m = (MonadReader FetcherConf m, PoF m)++readJson :: (FetcherM m, FromJSON a) => Text -> [Text] -> m a+readJson prg prgArgs = do+ asks fetcherArgs <&> rawNixCache >>= \case+ Tagged Nothing -> readJsonDirect prg prgArgs+ Tagged (Just c) -> readJsonCached c prg prgArgs++data RawFlakeOrigin+ = RawFlakeOrigin+ { flakeOriginType :: Text+ , flakeOriginOwner :: Maybe Text+ , flakeOriginRepo :: Maybe Text+ , flakeOriginRev :: Maybe Text+ , flakeOriginUrl :: Maybe Text+ } deriving (Show, Eq, Generic)++instance FromJSON RawFlakeOrigin where+ parseJSON = genericParseJSON+ (defaultOptions { fieldLabelModifier = fmap toLower . drop (length ("flakeOrigin" :: String)) })++data RawNode+ = RawNode+ { flake :: Maybe Bool+ , original :: Maybe RawFlakeOrigin+ } deriving (Show, Eq, Generic)+instance FromJSON RawNode++newtype RawLocks+ = RawLocks+ { nodes :: Map Text RawNode+ } deriving (Show, Eq, Generic)+instance FromJSON RawLocks++data RawFlakeInfo+ = RawFlakeInfo+ { revision :: Text+ , description :: Maybe Text+ , lastModified :: Integer+ , locks :: RawLocks+ } deriving (Show, Eq, Generic)+instance FromJSON RawFlakeInfo++-- Filesystem Size Used Avail Use% Mounted on+-- /dev/nvme0n1p5 907G 485G 376G 57% /+dfOutputRegex :: String+dfOutputRegex = " (1?[0-9]{1,2}[%])"++parseDfLine :: String -> Maybe Percent+parseDfLine l =+ case getAllTextSubmatches (l =~ dfOutputRegex) of+ ([_full, pG ] :: [String]) -> parsePercent pG+ _ -> Nothing++rootDiskUsedSpacePercent :: FetcherM m => m Percent+rootDiskUsedSpacePercent = do+ liftIO (readProcess "df" ["-h", "/"] "") <&> mapMaybe (parseDfLine . toString) . lines . toText >>= \case+ [] -> throwIO . stringException $ "No result from df -h /"+ [r] -> do+ $(logDebug) $ "Root volume used space: " <> show r+ pure r+ mu -> throwIO . stringException $ "Multiple results " <> show mu++runNixCG :: FetcherM m => m ()+runNixCG = liftIO $ callProcess "nix-store" ["--gc"]++checkDiskFreeSpace :: FetcherM m => m ()+checkDiskFreeSpace = do+ Tagged criticalDiskUsage <- runNixGCIfUsedMoreThan <$> asks fetcherArgs+ diskUsage <- rootDiskUsedSpacePercent+ if criticalDiskUsage <= diskUsage+ then runNixCG+ else $(logDebug) "Skip nix GC step"++nixFlakeInfo :: FetcherM m => FlakeUrl -> m RawFlakeInfo+nixFlakeInfo (FlakeUrl fu) =+ readJson "nix" ["flake", "metadata", "--json", fu]++nixCurrentArch :: MonadIO m => m Architecture+nixCurrentArch =+ Architecture . toText <$>+ liftIO (readProcess "nix" (toString <$> words "eval --impure --raw --expr builtins.currentSystem") "")++data RawFlakeOutputs+ = RawFlakeOutputs+ { packages :: Maybe (Map Architecture (Map PackageName ()))+ , nixosModules :: Maybe (Map Text ())+ } deriving (Show, Eq, Generic)++instance FromJSON RawFlakeOutputs++nixFlakeShow :: FetcherM m => FlakeUrl -> m RawFlakeOutputs+nixFlakeShow (FlakeUrl fu) =+ readJson "nix" ["flake", "show", "--json", fu]++data RawLicense+ = RawLicense+ { deprecated :: Bool+ , free :: Bool+ , fullName :: Text+ , shortName :: Text+ , spdxId :: Maybe Text+ , url :: Maybe Text+ } deriving (Show, Eq, Generic)++instance FromJSON RawLicense where++newtype RawPlatform = RawPlatform { unRawPlatform :: Text } deriving (Show, Eq)++instance FromJSON RawPlatform where+ parseJSON (A.String x) = pure $ RawPlatform x+ parseJSON _ = pure $ RawPlatform "trash"++data RawPackage+ = RawPackage+ { broken :: Maybe Bool+ , description :: Maybe Text+ , insecure :: Maybe Bool+ , license :: Maybe (Some RawLicense)+ , name :: Maybe Text+ , platforms :: Maybe [RawPlatform]+ , unfree :: Maybe Bool+ , unsupported :: Maybe Bool+ } deriving (Show, Eq, Generic)++instance FromJSON RawPackage++nixEvalPkgMeta :: FetcherM m => FlakeUrl -> Architecture -> PackageName -> m RawPackage+nixEvalPkgMeta (FlakeUrl fu) (Architecture arch) (PackageName pn) =+ readJson "nix" ["eval", "--json", fu <> "#packages." <> arch <> "." <> pn <> ".meta" ]++rawPackageToPackageInfo :: PackageName -> RawPackage -> PackageInfo+rawPackageToPackageInfo pn rp =+ PackageInfo+ { description = rp.description+ , license = shortName <$> join (maybeToList (fmap someToList rp.license))+ , name = maybe pn PackageName rp.name+ , platforms = unRawPlatform <$> fromMaybe [] rp.platforms+ , unfree = rp.unfree+ , broken = rp.broken+ }++forkWhile :: MonadUnliftIO m => (t -> m a) -> (t -> m b) -> t -> m b+forkWhile f m a = withAsync (f a) (\_ -> m a)+++metaFlakeFromUrl :: FetcherM m => FlakeUrl -> m MetaFlake+metaFlakeFromUrl fu = do+ checkDiskFreeSpace+ rfi <- nixFlakeInfo fu+ curArch <- asks arch+ rfo <- nixFlakeShow fu+ let archPkgs = fmap M.keys . M.filterWithKey (\a _ps -> a == curArch) $ fromMaybe mempty rfo.packages+ metaPackages <- M.fromList <$> mapM mapArchPkgs (M.toList archPkgs)+ pure MetaFlake+ { description = rfi.description+ , packages = metaPackages+ , rev = rfi.revision+ , hasNixOsModules = not $ null rfo.nixosModules+ , flakeDeps = []+ }+ where+ getPackageInfo arch pkgName = nixEvalPkgMeta fu arch pkgName <&> rawPackageToPackageInfo pkgName+ mapArchPkgs (arch, pkgNames) =+ (arch, ) . M.fromList <$> mapM (\pkgName -> (pkgName,) <$> getPackageInfo arch pkgName)+ (filter (/= PackageName "default") pkgNames)++uploadFlakeAndFetch :: (FetcherM m) =>+ Maybe (FlakeUrl, Either Text MetaFlake)+ -> m ()+uploadFlakeAndFetch f = do+ fctx <- ask+ let fetr = FetcherReq fctx.fetcherArgs.fetcherId f fctx.fetcherSecret+ rqb = ReqBodyJson fetr+ $(logDebug) $ "sending " <> show fetr+ jr <- recovering+ (fibonacciBackoff 100_000 <> limitRetries 6)+ [ \_rs -> Handler $+ \case+ he@VanillaHttpException {} -> do+ $(logError) $ "Vanila Exception " <> show he+ case isStatusCodeException he of+ Nothing -> pure False+ Just r ->+ case responseStatusCode r of+ scode ->+ pure $ scode < 400 || scode >= 500+ oe -> do+ $(logError) $ "Other Exception " <> show oe+ pure False+ ]+ (\_ ->+ runReq noRetry $ dynReq POST fctx.fetUrl "fetch-new-flake-submitions" rqb jsonResponse)+ $(logDebug) $ "Response from WS for " <> show (fmap fst f) <> ": " <> show (responseBody jr)+ case responseBody jr of+ Nothing -> uploadFlakeAndFetch Nothing+ Just fu -> catchAny (go fctx fu) (onEx fctx fu)+ where+ onEx fctx fu e = do+ $(logError) $ "nix failed for " <> show fu <> " with " <> show e+ uploadFlakeAndFetch $ fctx.flakeLoser (Just (fu, Left $ show e))+ go fctx fu =+ uploadFlakeAndFetch . fctx.flakeLoser . Just . (fu,) . Right+ =<< forkWhile launchHeartbeats metaFlakeFromUrl fu++noRetry :: HttpConfig+noRetry =+ defaultHttpConfig+ { httpConfigRetryJudgeException = \_ _ -> False+ , httpConfigRetryJudge = \_ _ -> False+ }++runFetcher :: PoF m => FetcherCmdArgs -> FetcherSecret -> m ()+runFetcher fa fsec = do+ fac <- loadFetcherAutoConf fa.webServiceUrl+ let serviceUrl = setResponseTimeout fa.webServiceUrl . coerce . (1 + ) $ untag fac.httpMinTimeout+ $(logInfo) $ "Fetcher " <> show fa.fetcherId <> " started for " <> show serviceUrl <> " with " <> show fac+ ca <- nixCurrentArch+ forever $ do+ recoverAll+ (capDelay (toMs (6 :: Second)) (fibonacciBackoff (toMs (1 :: Second))))+ (\_ ->+ runReaderT (uploadFlakeAndFetch Nothing)+ $ FetcherConf serviceUrl ca fsec fa looseF fac)+ where+ looseF :: forall a. Maybe a -> Maybe a+ looseF = if untag fa.looseFlakes+ then const Nothing+ else id++launchHeartbeats :: FetcherM m => FlakeUrl -> m ()+launchHeartbeats fu = do+ $(logInfo) "Fetcher Heartbeat thread is forked"+ ctx <- ask+ forever $ do+ sendHeartbeat fu+ threadDelay . unPeriod $ untag ctx.confFromWs.heartbeatPeriod++sendHeartbeat :: FetcherM m => FlakeUrl -> m ()+sendHeartbeat fu = do+ ctx <- ask+ responseBody <$> runReq noRetry+ (dynReq' POST ctx.fetUrl (\u -> u /: "fetcher" /: "heartbeat") (ReqBodyJson $ fhb ctx) jsonResponse)+ where+ fhb ctx = FetcherHeartbeat+ { fetcherId = ctx.fetcherArgs.fetcherId+ , workingOn = fu+ , fetcherSecret = ctx.fetcherSecret+ }++loadFetcherAutoConf :: PoF m => DynamicUrl -> m FetcherAutoConfig+loadFetcherAutoConf serviceUrl = do+ r <- runReq defaultHttpConfig $ dynReq' GET serviceUrl (\ur -> ur /: "fetcher" /: "config") NoReqBody jsonResponse+ pure $ responseBody r
@@ -0,0 +1,128 @@+{-# LANGUAGE DuplicateRecordFields #-}+module PieceOfFlake.Flake where++import Data.Aeson ( FromJSONKey, ToJSONKey, encode )+import Data.Map.Strict qualified as M+import Data.SafeCopy ( deriveSafeCopy, base )+import Data.Text qualified as T+import PieceOfFlake.Prelude hiding (Map)+import Text.Blaze ( ToMarkup )+import Yesod.Core+ ( typeJson,+ ToContent(..),+ ToTypedContent(..),+ TypedContent(TypedContent), PathPiece )++newtype RawFlakeUrl = RawFlakeUrl Text+ deriving newtype (Show, FromJSON, ToJSON)++newtype FlakeUrl = FlakeUrl { unFlakeUrl :: Text }+ deriving newtype (Show, Read, Eq, Ord, ToMarkup, ToJSON, FromJSON,+ Hashable, ToContent, ToTypedContent, IsString, ToText, PathPiece)++deriveSafeCopy 1 'base ''FlakeUrl++instance ToContent (Maybe FlakeUrl) where+ toContent = toContent . encode+instance ToTypedContent (Maybe FlakeUrl) where+ toTypedContent = TypedContent typeJson . toContent++instance ToContent [FlakeUrl] where+ toContent = toContent . encode+instance ToTypedContent [FlakeUrl] where+ toTypedContent = TypedContent typeJson . toContent++repoOfFlakeUrl :: FlakeUrl -> Text+repoOfFlakeUrl (FlakeUrl fu) = T.drop 1 $ T.dropWhile (/= '/') fu++newtype Architecture = Architecture Text+ deriving newtype+ ( Show, Eq, Ord, Hashable+ , ToJSON, FromJSON, ToJSONKey, FromJSONKey+ , IsString, ToText, ToMarkup+ )+deriveSafeCopy 1 'base ''Architecture+newtype PackageName = PackageName Text+ deriving newtype+ ( Show, Eq, Ord, Hashable+ , ToJSON, FromJSON, ToJSONKey, FromJSONKey+ , IsString, ToText, ToMarkup+ )+deriveSafeCopy 1 'base ''PackageName+data PackageInfo+ = PackageInfo+ { description :: Maybe Text+ , license :: [ Text ]+ , name :: PackageName+ , unfree :: Maybe Bool+ , platforms :: [ Text ]+ , broken :: Maybe Bool+ } deriving (Show, Eq, Generic)+deriveSafeCopy 1 'base ''PackageInfo+instance ToJSON PackageInfo+instance FromJSON PackageInfo++data MetaFlake+ = MetaFlake+ { description :: Maybe Text+ , packages :: M.Map Architecture (M.Map PackageName PackageInfo)+ , hasNixOsModules :: Bool+ , rev :: Text+ , flakeDeps :: [ FlakeUrl ]+ } deriving (Show, Eq, Generic)+deriveSafeCopy 1 'base ''MetaFlake++instance ToJSON MetaFlake+instance FromJSON MetaFlake++newtype IpAdr = IpAdr Text deriving newtype (Show, Eq, Ord, ToJSON, FromJSON, ToMarkup)+deriveSafeCopy 1 'base ''IpAdr+newtype FetcherId = FetcherId Text deriving newtype (Show, Eq, Ord, ToJSON, FromJSON, ToMarkup, Read, Hashable)+deriveSafeCopy 1 'base ''FetcherId++data Flake+ = SubmittedFlake+ { flakeUrl :: FlakeUrl+ , submittedAt :: UtcBox+ , submittedFrom :: IpAdr+ }+ | FlakeIsBeingFetched+ { flakeUrl :: FlakeUrl+ , submitionFetchedAt :: UtcBox+ , fetcherId :: FetcherId+ }+ | BadFlake+ { flakeUrl :: FlakeUrl+ , fetcherRespondedAt :: UtcBox+ , error :: Text+ }+ | FlakeFetched+ { flakeUrl :: FlakeUrl+ , uploadedAt :: UtcBox+ , meta :: MetaFlake+ }+ | FlakeIndexed+ { flakeUrl :: FlakeUrl+ , indexedAt :: UtcBox+ , meta :: MetaFlake+ }+ deriving (Show, Eq, Generic)++deriveSafeCopy 1 'base ''Flake+instance ToJSON Flake+instance FromJSON Flake++instance ToContent Flake where+ toContent = toContent . encode+instance ToTypedContent Flake where+ toTypedContent = TypedContent typeJson . toContent++isIndexed :: Flake -> Bool+isIndexed FlakeIndexed {} = True+isIndexed _ = False++linkUrl :: FlakeUrl -> Text+linkUrl (FlakeUrl (T.stripPrefix "github:" -> Just s)) =+ "https://github.com/" <> s+linkUrl (FlakeUrl s) =+ "#not-gh-link-" <> s
@@ -0,0 +1,398 @@+module PieceOfFlake.Flake.Repo where++import Data.Acid ( AcidState )+import ListT qualified as L+import PieceOfFlake.Acid ( AcidFlakes )+import PieceOfFlake.Flake+ ( FlakeUrl(FlakeUrl),+ Flake(indexedAt, FlakeIndexed, submitionFetchedAt, FlakeFetched,+ BadFlake, FlakeIsBeingFetched, SubmittedFlake, submittedAt,+ submittedFrom, fetcherId, flakeUrl, fetcherRespondedAt),+ FetcherId,+ IpAdr(..),+ MetaFlake,+ RawFlakeUrl(..) )+import PieceOfFlake.CmdArgs+ ( WsCmdArgs(allowResubmitIndexedFlakeIn, fetcherHeartbeatPeriod,+ badFlakeMaxAge, allowResubmitBadFlakeIn) )+import PieceOfFlake.Index ( FlakeIndex, indexNewFlake )+import PieceOfFlake.Prelude hiding (Map, show)+import PieceOfFlake.Prelude qualified as P+import PieceOfFlake.Stats+ ( RepoStatsF(totalFlakeUploadsSinceRestart, badFlakes,+ fetchingFlakes, fetchedFlakes, meanFetchTime, submittedFlakes, meanTimeInFetchQueue),+ addTimeDif )+import PieceOfFlake.Stm ( newTQueueIO, readTQueue, writeTQueue, TQueue, atomicalog )+import PieceOfFlake.WebService+ ( FetcherHeartbeat(fetcherId), FetcherSecret )+import StmContainers.Map+ ( Map, delete, insert, listTNonAtomic, lookup, newIO )+import Text.Regex.TDFA ( (=~) )+import UnliftIO.Exception (bracket_)++data FetcherState+ = FetcherState+ { workingOn :: Maybe FlakeUrl+ , lastHeartbeatAt :: UtcBox+ }++data FlakeRepo+ = FlakeRepo+ { flakes :: Map FlakeUrl Flake+ , fetcherSecret :: FetcherSecret+ , flakeIndex :: FlakeIndex+ , wsArgs :: WsCmdArgs+ , acidFlakes :: AcidState AcidFlakes+ , repoStats :: RepoStatsF TVar+ , fetchers :: Map FetcherId FetcherState+ , fetcherQueue :: TQueue (Maybe FlakeUrl)+ , fetcherQueueLen :: TVar Int+ , acidQueue :: TQueue (FlakeUrl, Flake)+ , pollingFetchers :: TVar Int+ }++mkFlakeRepo :: MonadIO m =>+ FetcherSecret ->+ WsCmdArgs ->+ FlakeIndex ->+ Map FlakeUrl Flake ->+ AcidState AcidFlakes ->+ RepoStatsF TVar ->+ m FlakeRepo+mkFlakeRepo fetSec cmdA fi flakesMap acidFlakeStorage rs = do+ liftIO $+ FlakeRepo flakesMap fetSec fi cmdA acidFlakeStorage rs <$>+ newIO <*>+ newTQueueIO <*>+ newTVarIO 0 <*>+ newTQueueIO <*>+ newTVarIO 0++trySubmitFlakeToRepo :: PoF m => IpAdr -> FlakeRepo -> FlakeUrl -> m (Either Text Flake)+trySubmitFlakeToRepo ip fr fu = do+ atomicalog $ do+ lift (lookup fu fr.flakes) >>= \case+ Nothing -> submitFlakeToRepo . mkUtcBox =<< getCurrentTime+ Just bf@BadFlake {} ->+ doAfter bf.fetcherRespondedAt+ (\fra -> do+ now <- lift $ getTimeAfter fra+ if now `diffUTCTime` fra > untag fr.wsArgs.allowResubmitBadFlakeIn then do+ $(logInfo) $ "Resubmit bad flake " <> P.show fu+ submitFlakeToRepo $ mkUtcBox now+ else+ pure . Left $ "Flake resubmitted within " <> P.show (untag fr.wsArgs.allowResubmitBadFlakeIn))+ Just fi@FlakeIndexed {} ->+ doAfter fi.indexedAt+ (\fra -> do+ now <- lift $ getTimeAfter fra+ if now `diffUTCTime` fra > untag fr.wsArgs.allowResubmitIndexedFlakeIn then do+ $(logInfo) $ "Resubmit indexed flake " <> P.show fu+ submitFlakeToRepo $ mkUtcBox now+ else+ pure . Left $ "Indexed flake resubmitted within " <> P.show (untag fr.wsArgs.allowResubmitIndexedFlakeIn))+ Just f ->+ pure $ Right f+ where+ submitFlakeToRepo now = do+ ql <- lift (readTVar fr.fetcherQueueLen)+ if ql > 1000+ then pure $ Left "Submition Queue is full"+ else do+ lift $ do+ modifyTVar' fr.repoStats.submittedFlakes (1 +)+ modifyTVar' fr.repoStats.totalFlakeUploadsSinceRestart (1 +)+ modifyTVar' fr.fetcherQueueLen (1 +)+ writeTQueue fr.fetcherQueue $ Just fu+ fql <- lift $ readTVar fr.fetcherQueueLen+ $(logInfo) $ "Fetcher queue increased to " <> P.show fql+ let f = SubmittedFlake fu now ip in do+ lift $ insert f fu fr.flakes+ pure $ Right f++stmBracket_ :: PoF m => STM a -> STM b -> WriterLoggingT STM c -> m c+stmBracket_ init' recycle mainAction =+ bracket_ (atomically init') (atomically recycle) (atomicalog mainAction)++popFlakeSubmition :: PoF m => FlakeRepo -> FetcherId -> m (Maybe FlakeUrl)+popFlakeSubmition fr ftid =+ stmBracket_+ (modifyTVar' fr.pollingFetchers (1 + ))+ (modifyTVar' fr.pollingFetchers ((-1) + ))+ (popFlakeSubmitionStm ftid fr) >>= mapM+ (\(fu, Tagged ifq) -> do+ addTimeDif fr.repoStats.meanTimeInFetchQueue ifq+ pure fu)++data TimeInFetchQueue++popFlakeSubmitionStm ::+ FetcherId ->+ FlakeRepo ->+ WriterLoggingT STM (Maybe (FlakeUrl, Tagged TimeInFetchQueue NominalDiffTime))+popFlakeSubmitionStm ftid fr =+ lift (lookup ftid fr.fetchers) >>= \case+ Nothing -> do+ $(logDebug) $ "Fetcher " <> P.show ftid <> " asks for flake url from scratch"+ fromScratch+ Just FetcherState { workingOn = Nothing } -> do+ $(logDebug) $ "Fetcher " <> P.show ftid <> " asks for flake url from scratch2"+ fromScratch+ Just FetcherState { workingOn = Just lostFu } -> do+ $(logWarn) $ "Fetcher " <> P.show ftid <> " asked for a next flake url, but "+ <> "has not responded about " <> P.show lostFu+ fetcherResume lostFu+ where+ fromScratch = do+ fSub <- lift $ readTQueue fr.fetcherQueue+ lift $ modifyTVar' fr.fetcherQueueLen (\x -> x - 1)+ fql <- lift $ readTVar fr.fetcherQueueLen+ $(logInfo) $ "Fetcher queue decreased to " <> P.show fql+ case fSub of+ Nothing -> pure Nothing+ Just fu -> resumeWithFu fu+ fetcherResume fu = do+ lift (lookup fu fr.flakes) >>= \case+ Just (FlakeIsBeingFetched { flakeUrl, submitionFetchedAt })+ | flakeUrl == fu -> do+ doAfter submitionFetchedAt (\sa -> do+ now <- getTimeAfter sa+ pure $ Just (flakeUrl, Tagged $ now `diffUTCTime` sa))+ | otherwise -> do+ $(logError) $ "Error flake url mismatch " <> P.show fu <> " <> " <> P.show flakeUrl+ popFlakeSubmitionStm ftid fr+ Just ufs -> do+ $(logError) $ "Expected SumbittedFlake state but:" <> P.show ufs+ popFlakeSubmitionStm ftid fr+ Nothing -> do+ $(logError) $ "Error flake " <> P.show fu <> " is missing in map"+ popFlakeSubmitionStm ftid fr++ resumeWithFu fu = do+ lift (lookup fu fr.flakes) >>= \case+ Just (SubmittedFlake { flakeUrl, submittedAt })+ | flakeUrl == fu -> do+ lift $ do+ modifyTVar' fr.repoStats.submittedFlakes (flip (-) 1)+ modifyTVar' fr.repoStats.fetchingFlakes (1 +)++ doAfter submittedAt (\sa -> do+ now <- getTimeAfter sa+ assocFlakeWithFetcher now flakeUrl ftid fr.fetchers fr.flakes+ lift $ insert (FlakeIsBeingFetched flakeUrl (mkUtcBox now) ftid) fu fr.flakes+ pure $ Just (flakeUrl, Tagged $ now `diffUTCTime` sa))+ | otherwise -> do+ $(logError) $ "Error flake url mismatch " <> P.show fu <> " <> " <> P.show flakeUrl+ popFlakeSubmitionStm ftid fr+ Just ufs -> do+ $(logError) $ "Expected SumbittedFlake state but:" <> P.show ufs+ popFlakeSubmitionStm ftid fr+ Nothing -> do+ $(logError) $ "Error flake " <> P.show fu <> " is missing in map"+ popFlakeSubmitionStm ftid fr++deassocFlakeFromFetcher ::+ (MonadTrans t, Hashable key, MonadLogger (t STM), Show key) =>+ UTCTime n -> FlakeUrl -> key -> Map key FetcherState -> t STM ()+deassocFlakeFromFetcher now fu ftid fetchers = do+ lift (lookup ftid fetchers) >>= \case+ Nothing -> do+ $(logWarn) $ "Fetcher " <> P.show ftid <> " has no state"+ lift $ insert efs ftid fetchers+ Just FetcherState { workingOn = Nothing } ->+ $(logWarn) $ "Fetcher " <> P.show ftid <> " was not associated with any flake"+ Just FetcherState { workingOn = Just afu }+ | afu == fu -> do+ lift $ insert efs ftid fetchers+ $(logDebug) $ "Fetcher " <> P.show ftid <> " is diassociated from flake " <> P.show afu+ | otherwise -> do+ $(logWarn) $ "Fetcher " <> P.show ftid <> " was diassociated with flake " <> P.show afu+ <> " but returned " <> P.show fu+ lift $ insert efs ftid fetchers+ where+ efs = FetcherState { workingOn = Nothing, lastHeartbeatAt = mkUtcBox now }++assocFlakeWithFetcher :: (MonadTrans t, Hashable a, MonadLogger (t STM), Show a) =>+ UTCTime n -> FlakeUrl -> a -> Map a FetcherState -> Map FlakeUrl Flake -> t STM ()+assocFlakeWithFetcher now fu ftid fetchers flakes = do+ lift (lookup ftid fetchers) >>= \case+ Nothing -> do+ $(logDebug) $ "Fetcher " <> P.show ftid <> " got flake " <> P.show fu+ lift $ insert FetcherState { workingOn = Just fu, lastHeartbeatAt = mkUtcBox now } ftid fetchers+ Just FetcherState { workingOn = Nothing } -> do+ $(logDebug) $ "Fetcher " <> P.show ftid <> " got flake' " <> P.show fu+ lift $ insert FetcherState { workingOn = Just fu, lastHeartbeatAt = mkUtcBox now } ftid fetchers+ Just FetcherState { workingOn = Just lostFu }+ | lostFu == fu ->+ $(logDebug) $ "Fetcher " <> P.show ftid <> " resumes on " <> P.show fu+ | otherwise -> do+ lift $ insert FetcherState { workingOn = Just fu, lastHeartbeatAt = mkUtcBox now } ftid fetchers+ lift (lookup lostFu flakes) >>= \case+ Nothing ->+ $(logWarn) $ "Fetcher " <> P.show ftid <>+ " lost a flake that is missing in the flake map " <> P.show lostFu+ Just fbf@FlakeIsBeingFetched {}+ | lostFu == fbf.flakeUrl -> do+ $(logWarn) $ "Flake " <> P.show lostFu <> " has been lost on fetcher " <> P.show ftid+ lift $ insert (BadFlake lostFu (mkUtcBox now) "Flake has been lost on fetcher. Try resubmit.")+ lostFu flakes+ | otherwise -> do+ $(logError) $ "Lost flake url " <> P.show lostFu <> " mismatch with url in map " <> P.show fbf+ Just ue ->+ $(logWarn) $ "Flake " <> P.show fu <> " is bound to fetcher " <>+ P.show ftid <> " with strange state " <> P.show ue++data FetcherReq+ = FetcherReq+ { fetcherId :: FetcherId+ , fetcherResponse :: Maybe (FlakeUrl, Either Text MetaFlake)+ , fetcherSecret :: FetcherSecret+ } deriving (Show, Eq, Generic)++instance FromJSON FetcherReq+instance ToJSON FetcherReq++-- | Store meta data for flake and ask for next flake submition+addFetchedFlake :: PoF m =>+ FlakeRepo ->+ FetcherId ->+ (FlakeUrl, Either Text MetaFlake) ->+ m (Maybe FlakeUrl)+addFetchedFlake fr ftid (fu, fetchedFlake) = do+ mapM_ (addTimeDif fr.repoStats.meanFetchTime) =<< atomicalog go+ popFlakeSubmition fr ftid+ where+ go = do+ lift (lookup fu fr.flakes) >>= \case+ Just (FlakeIsBeingFetched exFu past _fid)+ | fu == exFu ->+ doAfter past $ \p -> do+ now <- lift $ getTimeAfter p+ deassocFlakeFromFetcher now fu ftid fr.fetchers+ case fetchedFlake of+ Left e -> do+ lift $ do+ modifyTVar' fr.repoStats.fetchingFlakes (flip (-) 1)+ modifyTVar' fr.repoStats.badFlakes (1 +)+ insert (BadFlake fu (mkUtcBox now) e) fu fr.flakes+ $(logInfo) $ "Fetching flake " <> P.show fu <> " failed in " <> P.show (now `diffUTCTime` p)+ pure Nothing+ Right meta -> do+ let f = FlakeFetched fu (mkUtcBox now) meta+ lift $ do+ modifyTVar' fr.repoStats.fetchingFlakes (flip (-) 1)+ modifyTVar' fr.repoStats.fetchedFlakes (1 +)+ insert f fu fr.flakes+ let fetchTime = now `diffUTCTime` p+ $(logInfo) $ "Flake " <> P.show fu <> " is fetched in " <> P.show fetchTime+ lift $ writeTQueue fr.acidQueue (fu, f)+ indexNewFlake fr.flakeIndex fu+ pure $ Just fetchTime+ | otherwise -> do+ $(logError) $ "Error flake url mismatch " <> P.show fu <> " <> " <> P.show exFu+ pure Nothing+ Just ufs -> do+ $(logError) $ "Expected FlakeIsBeingFetched state but: " <> P.show ufs+ pure Nothing+ Nothing -> do+ $(logError) $ "Error flake " <> P.show fu <> " is missing in map"+ pure Nothing++sendEmptyFlakeSubmition :: PoF m => FlakeRepo -> m ()+sendEmptyFlakeSubmition fr =+ atomicalog $ do+ fql <- lift $ readTVar fr.fetcherQueueLen+ when (fql == 0) $ do+ numOfBlockedThreads <- lift $ readTVar fr.pollingFetchers+ $(logInfo) $ "Send " <> P.show numOfBlockedThreads <> " empty Flake Submition(s)"+ replicateM_ numOfBlockedThreads $ do+ lift $ do+ writeTQueue fr.fetcherQueue Nothing+ modifyTVar' fr.fetcherQueueLen (1 +)++selectBadOldFlakes :: MonadIO m => FlakeRepo -> m [ FlakeUrl ]+selectBadOldFlakes fr =+ liftIO (L.foldMaybe filterBad [] (listTNonAtomic fr.flakes))+ where+ filterBad selected = \case+ (_, BadFlake { flakeUrl }) -> pure . Just $ flakeUrl : selected+ _ -> pure Nothing++removeOldBadFlakes :: PoF m => FlakeRepo -> m ()+removeOldBadFlakes fr = do+ threadDelay $ untag fr.wsArgs.badFlakeMaxAge `div` 2+ badFlakes <- selectBadOldFlakes fr+ forM_ badFlakes $ \fu ->+ atomicalog $ do+ lift (lookup fu fr.flakes) >>= \case+ Just bf@BadFlake {} ->+ doAfter bf.fetcherRespondedAt $ \fra -> do+ now <- getTimeAfter fra+ when (now `diffUTCTime` fra > toNominal (untag fr.wsArgs.badFlakeMaxAge)) $ do+ $(logInfo) $ "Delete old bad flake " <> P.show fu+ lift $ delete fu fr.flakes+ _ -> pure ()++validateRawFlakeUrl :: RawFlakeUrl -> Maybe FlakeUrl+validateRawFlakeUrl (RawFlakeUrl rfu) =+ if rfu =~ ("^github:[a-zA-Z0-9._-]+[/][a-zA-Z0-9._-]+$" :: Text)+ then pure $ FlakeUrl rfu+ else Nothing++fetcherIsAlive :: PoF m => FlakeRepo -> FetcherHeartbeat -> m ()+fetcherIsAlive fr fhb =+ atomicalog $ do+ now <- getCurrentTime+ lift $ do+ lookup fhb.fetcherId fr.fetchers >>= \case+ Nothing ->+ insert+ FetcherState { workingOn = Nothing+ , lastHeartbeatAt = mkUtcBox now+ }+ fhb.fetcherId fr.fetchers+ Just ftst ->+ insert ftst { lastHeartbeatAt = mkUtcBox now } fhb.fetcherId fr.fetchers++resubmitFlakesFetchingByZombie :: PoF m => FlakeRepo -> m ()+resubmitFlakesFetchingByZombie fr = do+ fs <- liftIO (L.toList (listTNonAtomic fr.fetchers))+ atomicalog $ do+ forM_ fs $ \(ftid, ftst) -> do+ case ftst.workingOn of+ Nothing -> do+ $(logInfo) $ "Delete state of " <> P.show ftid <> " fetcher because of idling"+ lift $ delete ftid fr.fetchers+ Just wasWorkingOnFu -> do+ doAfter ftst.lastHeartbeatAt $ \ha -> do+ now <- getTimeAfter ha+ let maxPeriod = (*2) . toNominal $ untag fr.wsArgs.fetcherHeartbeatPeriod+ when (now `diffUTCTime` ha > maxPeriod) $ do+ $(logWarn) $ "Fetcher " <> P.show ftid <> " must be dead since " <> P.show now+ lift $ delete ftid fr.fetchers+ lift (lookup wasWorkingOnFu fr.flakes) >>= \case+ Nothing ->+ $(logError) $ "Fetcher " <> P.show ftid <> " was working an ghost flake: " <> P.show wasWorkingOnFu+ Just fif@FlakeIsBeingFetched {}+ | fif.fetcherId /= ftid ->+ $(logError) $ "Failed to resubmit flake " <> P.show wasWorkingOnFu <>+ " due it is processed by another fetcher " <> P.show fif.fetcherId+ | fif.flakeUrl /= wasWorkingOnFu -> do+ $(logWarn) $ "Flake map is corrupted on" <>+ P.show wasWorkingOnFu <> " /= " <> P.show fif.flakeUrl+ | otherwise -> do+ $(logInfo) $ "Resubmit flake " <> P.show wasWorkingOnFu <>+ " after fetcher " <> P.show ftid <> " died"+ lift $ do+ insert+ SubmittedFlake+ { flakeUrl = wasWorkingOnFu+ , submittedAt = mkUtcBox now+ , submittedFrom = IpAdr "127.0.0.1"+ }+ wasWorkingOnFu fr.flakes+ modifyTVar' fr.fetcherQueueLen (1 +)+ writeTQueue fr.fetcherQueue $ Just wasWorkingOnFu+ Just o ->+ $(logError) $ "Failed to resubmit flake " <> P.show wasWorkingOnFu <>+ " due it is not in IsBeingFetching state but " <> P.show o
@@ -0,0 +1,118 @@+module PieceOfFlake.Http where++import Data.ByteString qualified as BS+import Language.Haskell.TH.Syntax (qLocation)+import Network.Wai.Handler.WarpTLS ( runTLS, tlsSettings, TLSSettings )+import Network.Wai.Handler.Warp+ ( Settings,+ setBeforeMainLoop,+ setMaxTotalHeaderLength,+ setOnException,+ setPort,+ setServerName,+ setSlowlorisSize,+ setTimeout,+ runSettings,+ defaultSettings,+ defaultShouldDisplayException )+import Network.Wai+import Network.Wai.Middleware.Autohead+import Network.Wai.Middleware.AcceptOverride+import Network.Wai.Middleware.MethodOverride ( methodOverride )+import Network.Wai.Middleware.Gzip+ ( gzip, defaultGzipSettings, GzipSettings(gzipCheckMime) )+import Network.Wai.Middleware.RequestLogger+ ( OutputFormat(Apache),+ defaultRequestLoggerSettings,+ mkRequestLogger,+ Destination(Logger),+ RequestLoggerSettings(outputFormat, destination),+ IPAddrSource(FromSocket) )+import PieceOfFlake.CmdArgs+import PieceOfFlake.Page ( Ypp )+import PieceOfFlake.Prelude+import Yesod.Core+ ( Yesod(makeLogger, messageLoggerSource), YesodDispatch+ , toWaiAppYre, makeSessionBackend, getGetMaxExpires+ , defaultGen+ )+import Yesod.Core.Types ( Logger, YesodRunnerEnv (..), loggerSet )++toWaiApp :: YesodDispatch site => site -> IO Application+toWaiApp site = do+ logger <- makeLogger site+ toWaiAppLogger logger site++toWaiAppLogger :: YesodDispatch site => Logger -> site -> IO Application+toWaiAppLogger logger site = do+ sb <- makeSessionBackend site+ getMaxExpires <- getGetMaxExpires+ let yre = YesodRunnerEnv+ { yreLogger = logger+ , yreSite = site+ , yreSessionBackend = sb+ , yreGen = defaultGen+ , yreGetMaxExpires = getMaxExpires+ }+ messageLoggerSource+ site+ logger+ $(qLocation >>= liftLoc)+ "yesod-core"+ LevelInfo+ (toLogStr ("Application launched" :: ByteString))+ middleware <- mkDefaultMiddlewares logger+ return $ middleware $ toWaiAppYre yre++mkDefaultMiddlewares :: Logger -> IO Middleware+mkDefaultMiddlewares logger = do+ logWare <- mkRequestLogger+ defaultRequestLoggerSettings+ { destination = Network.Wai.Middleware.RequestLogger.Logger $ loggerSet logger+ , outputFormat = Apache FromSocket+ }+ return $ logWare . defaultMiddlewaresNoLogging++defaultMiddlewaresNoLogging :: Middleware+defaultMiddlewaresNoLogging = acceptOverride . autohead . gzip gzipSettings . methodOverride+ where+ gzipSettings = defaultGzipSettings { gzipCheckMime = check }+ check = BS.isPrefixOf "text/html"++mkSettings :: Ypp -> WsCmdArgs -> Logger -> Settings+mkSettings yp ca logger =+ setPort port $+ setServerName "PieceOfFlake" $+ setOnException onEx $+ setSlowlorisSize 1024 $+ setMaxTotalHeaderLength 1024 $+ setBeforeMainLoop (putTextLn $ "Go " <> untag ca.baseUrl) $+ -- expected that Nginx restricts keepalive for non fetcher connections+ setTimeout (2 * fromIntegral (untag ca.noSubmitionHeartbeat))+ defaultSettings++ where+ port = untag ca.httpPortToListen+ shouldLog' = defaultShouldDisplayException+ onEx _ e =+ when (shouldLog' e) $+ messageLoggerSource+ yp+ logger+ $(qLocation >>= liftLoc)+ "yesod-core"+ LevelError+ (toLogStr $ "Exception from Warp: " ++ show e)++mkTlsSettings :: Tagged Cert FilePath -> Tagged CertKey FilePath -> TLSSettings+mkTlsSettings cert key = tlsSettings (untag cert) (untag key)++runPlain :: Settings -> Application -> IO ()+runPlain = runSettings++runWebService :: MonadIO m => WsCmdArgs -> Ypp -> m ()+runWebService ws y = liftIO $ do+ logger <- makeLogger y+ case liftA2 mkTlsSettings ws.certFile ws.keyFile of+ Nothing -> runPlain (mkSettings y ws logger) =<< toWaiApp y+ Just tlsSngs -> runTLS tlsSngs (mkSettings y ws logger) =<< toWaiApp y
@@ -0,0 +1,223 @@+module PieceOfFlake.Index where++import Data.HashPSQ qualified as PSQ+import Data.Map.Strict qualified as M+import Data.LruCache qualified as LRU+import Data.LruCache.Internal qualified as LRU+import Data.LruCache (LruCache)+import Data.SearchEngine+ ( Term,+ SearchConfig(documentFeatureValue, SearchConfig, documentKey,+ extractDocumentTerms, transformQueryTerm),+ SearchRankParameters(paramAutosuggestPostfilterLimit,+ SearchRankParameters, paramK1, paramB, paramFieldWeights,+ paramFeatureWeights, paramFeatureFunctions,+ paramResultsetSoftLimit, paramResultsetHardLimit,+ paramAutosuggestPrefilterLimit),+ SearchEngine,+ FeatureFunction(LogarithmicFunction),+ query,+ initSearchEngine,+ insertDoc,+ queryAutosuggest,+ ResultsFilter(NoFilter) )+import ListT qualified as L+import NLP.Tokenize.Text ( tokenize )+import PieceOfFlake.Flake+ ( FlakeUrl,+ repoOfFlakeUrl,+ PackageInfo(broken, description, license, name, unfree),+ MetaFlake(hasNixOsModules, description, packages),+ Flake(meta, uploadedAt, FlakeIndexed, FlakeFetched, flakeUrl),+ isIndexed )+import PieceOfFlake.CmdArgs ( IndexQueryCacheSize )+import PieceOfFlake.Prelude hiding (pi, Map)+import PieceOfFlake.Stm+ ( readTQueue, writeTQueue, TQueue, newTQueueIO, atomicalog )+import StmContainers.Map ( insert, listTNonAtomic, lookup, Map )+import PieceOfFlake.Stats+ ( RepoStatsF(meanTimeInIndexQueue, fetchedFlakes, indexedFlakes,+ meanIndexTime, meanSearchTime),+ addTimeDif )+import PieceOfFlake.TotalMath ( notZero, divNz, realToFracNz )++type FlakeSearchEngine = SearchEngine (FlakeUrl, MetaFlake) FlakeUrl () ()++data FlakeIndex+ = FlakeIndex+ { searchEngine :: TVar FlakeSearchEngine+ , indexerQueue :: TQueue FlakeUrl+ , indexerQueueLen :: TVar Int+ , queryCache :: TVar (LruCache Text UtcBox)+ , searchRequestCounter :: TVar Integer+ }++mkFlakeIndex :: MonadIO m => Tagged IndexQueryCacheSize Word -> m FlakeIndex+mkFlakeIndex (Tagged cs) = do+ liftIO $+ FlakeIndex <$>+ newTVarIO emptySearchEngine <*>+ newTQueueIO <*>+ newTVarIO 0 <*>+ newTVarIO (LRU.empty $ fromIntegral cs) <*>+ newTVarIO 0++packageInfoToTerms :: PackageInfo -> [Term]+packageInfoToTerms pi =+ concatMap tokenize pi.description <>+ concatMap tokenize pi.license <>+ [ toText pi.name ]+ <> maybeToList (fmap (\c -> if c then "unfree" else "free") pi.unfree)+ <> maybeToList (fmap (\c -> if c then "broken" else "unbroken") pi.broken)++extractTerms :: (FlakeUrl, MetaFlake) -> () -> [Term]+extractTerms (fu, mf) () =+ concatMap tokenize (maybeToList mf.description)+ <> [repoOfFlakeUrl fu] <> (toText <$> M.keys mf.packages)+ <> (concatMap packageInfoToTerms . concatMap M.elems $ M.elems mf.packages)+ <> memptyIfFalse mf.hasNixOsModules ["nixosModules"]++emptySearchEngine :: FlakeSearchEngine+emptySearchEngine =+ initSearchEngine+ SearchConfig+ { documentKey = fst+ , extractDocumentTerms = extractTerms+ , transformQueryTerm = const+ , documentFeatureValue = \_doc () -> 1+ }+ SearchRankParameters+ { paramK1 = 2 -- weight of repetition+ , paramB = const 0.4 -- normalize by length+ , paramFieldWeights = const 1+ , paramFeatureWeights = const 1+ , paramFeatureFunctions = const $ LogarithmicFunction 2+ , paramResultsetSoftLimit = 10+ , paramResultsetHardLimit = 30+ , paramAutosuggestPrefilterLimit = 10+ , paramAutosuggestPostfilterLimit = 10+ }++data FlakeIndexedSuc+ = FlakeIndexedSuc+ { inIndexQueue :: NominalDiffTime+ , indexingTook :: NominalDiffTime+ }++nothing :: Monad m => m a1 -> m (Maybe a2)+nothing m = do+ void m+ pure Nothing++consumeIndexQueue :: PoF m => RepoStatsF TVar -> Map FlakeUrl Flake -> FlakeIndex -> m ()+consumeIndexQueue rs fs fi = do+ r <- atomicalog $ do+ $(logInfo) "Wait for flakes to index for full text search"+ fu <- lift $ readTQueue fi.indexerQueue+ lift $ modifyTVar' fi.indexerQueueLen (\x -> x - 1)+ ql <- lift $ readTVar fi.indexerQueueLen+ $(logInfo) $ "Start index flake " <> show fu <> "; index queue " <> show ql+ lift (lookup fu fs) >>= \case+ Nothing -> do+ $(logError) $ "Flake " <> show fu <> " is missing"+ pure Nothing+ Just f -> indexFlake rs fi fs f+ mapM_ (\FlakeIndexedSuc { inIndexQueue, indexingTook } -> do+ addTimeDif rs.meanIndexTime indexingTook+ addTimeDif rs.meanTimeInIndexQueue inIndexQueue+ ) r++indexNewFlake :: (MonadLogger (t STM), MonadTrans t) => FlakeIndex -> FlakeUrl -> t STM ()+indexNewFlake fi fu = do+ iql <- lift $ do+ writeTQueue fi.indexerQueue fu+ modifyTVar' fi.indexerQueueLen (1 +)+ readTVar fi.indexerQueueLen+ $(logInfo) $ "Indexer queue increased to " <> show iql++indexFlake :: (MonadLogger (t STM), MonadTrans t) =>+ RepoStatsF TVar -> FlakeIndex -> Map FlakeUrl Flake -> Flake -> t STM (Maybe FlakeIndexedSuc)+indexFlake rs =+ indexFlake' $ do+ lift $ do+ modifyTVar' rs.fetchedFlakes (flip (-) 1)+ modifyTVar' rs.indexedFlakes (1 +)++indexFlake' :: (MonadLogger (t STM), MonadTrans t) =>+ t STM () -> FlakeIndex -> Map FlakeUrl Flake -> Flake -> t STM (Maybe FlakeIndexedSuc)+indexFlake' onIndexed fi fs f =+ case f of+ ff@FlakeFetched { flakeUrl, uploadedAt } ->+ doAfter uploadedAt $ \ua -> do+ beforeIndex <- lift $ getTimeAfter ua+ let fu = flakeUrl+ ixf = FlakeIndexed fu (mkUtcBox beforeIndex) ff.meta+ onIndexed+ lift $ do+ insert ixf fu fs+ modifyTVar' fi.searchEngine (insertDoc (fu, ff.meta))+ afterIndex <- lift $ getTimeAfter beforeIndex+ $(logInfo) $ "Finished index flake " <> show fu+ pure . Just $ FlakeIndexedSuc+ { inIndexQueue = beforeIndex `diffUTCTime` ua+ , indexingTook = afterIndex `diffUTCTime` beforeIndex+ }+ _nff -> do+ $(logError) $ "Flake " <> show f.flakeUrl <> " is not in the fetched state"+ pure Nothing++loadIndexFromScratch ::+ PoF m => RepoStatsF TVar -> FlakeIndex -> Map FlakeUrl Flake -> [(FlakeUrl, Flake)] -> m ()+loadIndexFromScratch rs fi fsm fs = do+ started <- getCurrentTime+ totalIndexed :: Int <- atomicalog $ do+ $(logInfo) "Started init full text search index population"+ r <- foldlM go 0 fs+ $(logInfo) "Ended init full text search index population"+ pure r+ dur <- flip diffUTCTime started <$> getTimeAfter started+ forM_ (realToFracNz <$> notZero totalIndexed) $ \ti ->+ addTimeDif rs.meanIndexTime $ dur `divNz` ti+ where+ go (i :: Int) = \case+ (_, ff@FlakeFetched {}) ->+ (i + ) . maybe 0 (const 1) <$> indexFlake' (lift $ modifyTVar' rs.indexedFlakes (1 +)) fi fsm ff+ (fu, nff) -> do+ lift $ insert nff fu fsm+ pure i++data FlakeSearchReq+ = FlakeSearchReq+ { searchPattern :: [Text]+ , skipBroken :: Bool+ } deriving (Show, Eq, Generic)++instance FromJSON FlakeSearchReq++listQueryCache :: PoF m => FlakeIndex -> m [ Text ]+listQueryCache fi =+ reverse . fmap (^._1) . sortWith (^._2) . PSQ.toList . LRU.lruQueue <$> readTVarIO fi.queryCache++findFlakes :: PoF m =>+ RepoStatsF TVar -> Map FlakeUrl Flake -> FlakeIndex -> FlakeSearchReq -> m [ FlakeUrl ]+findFlakes rs fs fi FlakeSearchReq { searchPattern = ps } =+ case concatMap tokenize ps of+ [] -> justLoadFirstNFlakes fs 30+ pst@(t1:_) -> do+ now <- getCurrentTime+ atomicalog (fromIdx now t1 pst) <*+ (addTimeDif rs.meanSearchTime . (`diffUTCTime` now) =<< getTimeAfter now)+ where+ fromIdx now t1 pst = do+ se <- lift $ do+ modifyTVar' fi.searchRequestCounter (1 +)+ modifyTVar' fi.queryCache (LRU.insert (unwords ps) (mkUtcBox now))+ readTVar fi.searchEngine+ $(logInfo) $ "Search flakes by " <> show pst+ let r = query se pst `alt` (fmap fst . snd $ queryAutosuggest se NoFilter [] t1)+ $(logInfo) $ "Found " <> show (length r) <> " by " <> show ps+ pure r++justLoadFirstNFlakes :: MonadIO m => Map FlakeUrl Flake -> Int -> m [ FlakeUrl ]+justLoadFirstNFlakes fs n =+ fmap fst . filter (isIndexed . snd) <$> liftIO (L.toList $ L.take (2 * n) (listTNonAtomic fs))
@@ -0,0 +1,654 @@+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE MultilineStrings #-}+module PieceOfFlake.Page where++import Data.Aeson ( encode )+import Data.Map.Strict (elems)+import PieceOfFlake.CmdArgs+ ( WsCmdArgs(logLevel, fetcherHeartbeatPeriod, noSubmitionHeartbeat,+ baseUrl),+ StaticCacheSeconds )+import PieceOfFlake.Flake+ ( FlakeUrl,+ Flake(..),+ linkUrl,+ IpAdr(IpAdr),+ MetaFlake(description, packages, rev),+ PackageInfo(broken, name, description, license, unfree) )+import PieceOfFlake.Flake.Repo+ ( FetcherReq(FetcherReq),+ FlakeRepo(wsArgs, fetcherSecret, flakes, fetcherQueueLen,+ flakeIndex, repoStats),+ trySubmitFlakeToRepo,+ popFlakeSubmition,+ addFetchedFlake,+ fetcherIsAlive,+ validateRawFlakeUrl )+import PieceOfFlake.Index ( findFlakes, listQueryCache, FlakeIndex (searchRequestCounter, indexerQueueLen) )+import PieceOfFlake.Prelude hiding (Map, error, pi, Handler)+import PieceOfFlake.Stats ( greadTraVar, renderRepoStats )+import PieceOfFlake.Th ( includeFile )+import PieceOfFlake.WebService+ ( FetcherAutoConfig(..),+ FetcherHeartbeat(fetcherSecret),+ FetcherSecret,+ Period(Period) )+import PieceOfFlake.Yesod+ ( bulmaLayout,+ clientAdrToDec4,+ getClientAdr,+ mp3Mime,+ sendStaticBs,+ staticFile,+ ContentEncoding(Br, Gzip),+ Mime(Mime),+ Ts(Ts),+ Unit(..) )++import StmContainers.Map ( lookup )+import Text.Blaze.Internal ( MarkupM )+import Yesod.Core+++data Ypp+ = Ypp+ { repo :: FlakeRepo+ , staticCache :: Tagged StaticCacheSeconds Word32+ }++mkYesod "Ypp" [parseRoutes|+/app.js AppJsR GET+/style.css StyleR GET+/bulma.min.css BulmaR GET+/robots.txt RobotsR GET+/sitemap.xml SiteMapR GET+/ HomeR GET+/search SearchR GET+/stats StatsR GET+/about AboutR GET+/fetcher/heartbeat FetcherHeartbeatR POST+/fetcher/config FetcherAutoConfigR GET+/flake/#FlakeUrl FlakeR GET+/flake/status/#FlakeUrl FlakeStatusR GET+/publication PublicationR GET+/favicon.svg FaviconR GET+/flake.svg FlakeSvgR GET+/github.svg GitHubR GET+/flush.mp3 FlushSoundR GET+/snow.mp3 SnowSoundR GET+/avalanche.mp3 AvalancheSoundR GET+/submit-flake SubmitFlakeR POST+/fetch-new-flake-submitions FetchNewFlakeSubmitionsR POST+/find-flakes FindFlakesR POST+|]++instance Yesod Ypp where+ approot = ApprootMaster $ untag . baseUrl . wsArgs . repo+ makeSessionBackend _ = pure Nothing+ maximumContentLength _ = pure . \case+ Nothing -> 1+ Just HomeR -> 1+ Just SubmitFlakeR -> 200+ Just FetchNewFlakeSubmitionsR -> 100000+ Just _ -> 500++ shouldLogIO (Ypp {repo}) _ l =+ pure $ l >= repo.wsArgs.logLevel+++getAppJsR, getFaviconR, getRobotsR, getGitHubR, getFlushSoundR :: Handler TypedContent+getSnowSoundR, getSiteMapR, getAvalancheSoundR, getStyleR, getBulmaR, getFlakeSvgR :: Handler TypedContent+getFlakeSvgR =+ staticFile (Mime typeSvg) $(includeFile "assets/flake.svg") $ fromList+ [ (Gzip, $(includeFile "assets/flake.svg.gz"))+ , (Br, $(includeFile "assets/flake.svg.br"))+ ]+getFaviconR =+ staticFile (Mime typeSvg) $(includeFile "assets/favicon.svg") $ fromList+ [ (Gzip, $(includeFile "assets/favicon.svg.gz"))+ , (Br, $(includeFile "assets/favicon.svg.br"))+ ]+getGitHubR =+ staticFile (Mime typeSvg) $(includeFile "assets/github.svg") $ fromList+ [ (Gzip, $(includeFile "assets/github.svg.gz"))+ , (Br, $(includeFile "assets/github.svg.br"))+ ]+getAppJsR =+ staticFile (Mime typeJavascript) $(includeFile "assets/app.js") $ fromList+ [ (Gzip, $(includeFile "assets/app.js.gz"))+ , (Br, $(includeFile "assets/app.js.br"))+ ]+getStyleR =+ staticFile (Mime typeCss) $(includeFile "assets/style.css") $ fromList+ [ (Gzip, $(includeFile "assets/style.css.gz"))+ , (Br, $(includeFile "assets/style.css.br"))+ ]+getBulmaR =+ staticFile (Mime typeCss) $(includeFile "assets/bulma.min.css") $ fromList+ [ (Gzip, $(includeFile "assets/bulma.min.css.gz"))+ , (Br, $(includeFile "assets/bulma.min.css.br"))+ ]+getFlushSoundR = sendStaticBs mp3Mime $(includeFile "assets/flush.mp3")+getSnowSoundR = sendStaticBs mp3Mime $(includeFile "assets/snow.mp3")+getAvalancheSoundR = sendStaticBs mp3Mime $(includeFile "assets/avalanche.mp3")+getSiteMapR = sendStaticBs (Mime typeXml) $(includeFile "assets/sitemap.xml")+getRobotsR = sendStaticBs (Mime typePlain) $(includeFile "assets/robots.txt")++instance ClockMonad (HandlerFor Ypp) where+ getCurrentTime = liftIO getCurrentTime+ getTimeAfter x = liftIO $ getTimeAfter x++getStatsR :: Handler Html+getStatsR = do+ Ypp { repo } <- getYesod+ queries <- listQueryCache repo.flakeIndex+ fetchQueueLen <- Tagged @"fetch" <$> readTVarIO repo.fetcherQueueLen+ idxQueueLen <- readTVarIO repo.flakeIndex.indexerQueueLen+ searchReq <- readTVarIO repo.flakeIndex.searchRequestCounter+ rs <- greadTraVar repo.repoStats+ bulmaLayout $ do+ setTitle "Stats"+ metaTags+ navBar+ [whamlet|+ <section class="section pt-5">+ ^{renderRepoStats searchReq idxQueueLen fetchQueueLen rs}+ <h2 class="title is-4 mb-3">+ Popular Queries+ $if null queries+ <div class="notification is-warning">+ No queries+ $else+ <div class=content>+ <ul>+ $forall q <- queries+ <li>+ #{q}+ |]++getSearchR :: Handler Html+getSearchR =+ bulmaLayout $ do+ setTitle "Search - A Piece of Flake"+ metaTags+ navBar+ [whamlet|+ <section class="section pt-5">+ <h1 class="title is-4 mb-3">+ Nix Flake Search++ <form method=post onsubmit="return searchFlakesBy(pattern.value)">+ <div class=field>+ <div class=control>+ <input type=text id=flake-pattern name=pattern+ class=input+ autofocus placeholder="vpn BSD3" value=""/>+ <div class="field is-grouped">+ <div class=control>+ <button class="button is-black">Find</button>+ <div class="field">+ <div>+ <p class="help notification is-info is-light" id=search-hint>+ Besides text from flake metadata the text index contains keywords:+ nixosModules, unfree, free, broken, and unbroken.+ <div class="field">+ <div class="is-hidden">+ <p class="help notification is-danger is-light" id=error-output>+ <div class="field">+ <div id=no-flakes-found class=is-hidden>+ <div class="notification is-warning is-light">+ No flakes were found. Try to simplify the search pattern.++ <div id=found-flakes class=is-hidden>+ <h2 class="title is-5 mb-1 pt-3">+ Found flakes+ <table class=table>+ <thead>+ <tr>+ <th>URL+ <tbody id=search-results>+ |]++getHomeR :: Handler Html+getHomeR = getSearchR++getPublicationR :: Handler Html+getPublicationR =+ defaultLayout $ do+ setTitle "Publication - A Piece Of Flake"+ metaTags+ navBar+ [whamlet|+ <div id=aniflakes>+ <img class="ani fall1" src="flake.svg">+ <img class="ani fall2" src="flake.svg">+ <img class="ani fall3" src="flake.svg">+ <img class="ani fall4" src="flake.svg">++ <img class="ani ani-fall" src="/flake.svg">+ <section class="section pt-4">+ <h1 class="title is-4 mb-3">+ Nix Flake Publication+ <div class=submition-form>+ <form method=post onsubmit="return submitFlake(url.value)">+ <div class=field>+ <label class=label>Flake URL+ <div class=control>+ <input type=text class=input name=url autofocus+ placeholder="github:owner/repository"/>+ <div class="field is-grouped">+ <div class=control>+ <button class="button is-black">Publish</button>+ <div class=field>+ <div id=bad-url class=is-hidden>+ <div class="notification is-danger content">+ Url does not fit the pattern:+ <ul>+ <li>+ <p>+ github:owner/project+ <li>+ <p>+ https://github.com/owner/project+ <div class=field>+ <div id=sumbitted-notification class=is-hidden>+ <div class="notification is-success">+ Flake has been submitted.+ Check flake status <a id=flake-link href="#">here+ <div class=field>+ <div id=error-output-hid class=is-hidden>+ <pre id=error-output class="notification is-danger error">+ |]++getAboutR :: Handler Html+getAboutR = defaultLayout $ do+ setTitle "About - A Piece Of Flake"+ metaTags+ navBar+ [whamlet| $newline always+ <section class="section pt-4">+ <h1 class="title is-4 mb-3">+ About+ <div class="content is-size-5 has-text-justified">+ <p>+ The main idea behind this project is to provide a quick and simple+ interface for publishing Nix flakes.++ <p>+ Flakes have become relatively mature and address the central+ repository issue. However, the Nixpkgs repository on GitHub still has+ more that 5k open issues and a comparable number of pull requests, and+ continues to receive many commits every day. Getting a pull request+ for a new tool merged into Nixpkgs can be difficult - the Nixpkgs+ README explicitly discourages people from submitting their "pet"+ projects.++ <p>+ The Nixpkgs repository is huge. It contains more than 120k packages, but+ the majority of them are not native to Nix. For example, about 10%+ are Haskell packages imported. Therefore, this large number cannot be+ used as a reliable measure of how well the publishing process is+ developed in Nix. For instance, the PyPy repository alone currently+ contains almost 900k packages.++ <p>+ It is also important to note Python is the most popular+ general-purpose programming language, and its publishing process was+ designed by programmers for programmers. Yet there is no pull-request+ step in the workflow. The interface is essentially "upload and+ forget", which has a significant positive impact on the conversion+ funnel of Python packages.++ <p>+ Flakes are easy to install, but the publishing workflow is not yet polished+ enough. The current approach to distributing flakes appears to have+ inherinted many characteristics of the Nixpkgs workflow.++ <p>+ For Nixpkgs, this was the natural way of development, because all+ derivations form a large and coupled Nix expression split across many+ files within a single Git repository.+ <p>+ <center>+ <a href="https://github.com/yaitskov/a-piece-of-flake">+ <img class=github src=/github.svg />+ |]++metaTags :: WidgetFor Ypp ()+metaTags =+ toWidgetHead+ [hamlet|+ <meta charset="utf-8" />+ <meta name="viewport" content="width=device-width, initial-scale=1" />+ <meta name="author" content="Daniil Iaitskov" />+ <meta name="keywords" content="nix flake repository" />+ <meta name="description" content="Nix Flake repository" />+ <link rel="shortcut icon" href="favicon.svg" type="image/x-icon">+ <link rel=stylesheet href=/bulma.min.css>+ <link rel=stylesheet href=/style.css>+ <script src="/app.js"></script>+ |]++getFlakeStatusR :: FlakeUrl -> Handler Text+getFlakeStatusR fu = do+ Ypp { repo } <- getYesod+ atomically (lookup fu repo.flakes) >>= \case+ Nothing -> do+ $(logDebug) $ "Not found " <> show fu+ pure "NotFound"+ Just f -> pure $ case f of+ SubmittedFlake {} -> "Submitted"+ FlakeIsBeingFetched {} -> "OnFetcher"+ BadFlake {} -> "Bad"+ FlakeFetched {} -> "Fetched"+ FlakeIndexed {} -> "Indexed"++getFlakeR :: FlakeUrl -> Handler Html+getFlakeR fu = do+ Ypp { repo } <- getYesod+ fw <- atomically (lookup fu repo.flakes) <&> \case+ Nothing -> flakeNotFound+ Just f -> flakeToWidget f++ bulmaLayout $ do+ setTitle $ show fu <> " - A Piece of Flake"+ metaTags+ navBar+ [whamlet|+ <section class="section pt-4">+ <h1 class="title is-4 mb-3">+ Flake+ ^{fw}+ |]+ where+ flakeStatusLifeCycle =+ [hamlet|+ <p>Request status life-cycle:+ <ul>+ <li>Submitted+ <li>On Fetcher+ <li>Fetched+ <li>Indexed - flake is discoverable by search request+ <p>+ <a href=@{StatsR}>+ Average processing time+ |]+ flakeNotIndexed =+ [hamlet|<div id=flake-is-not-indexed>+ |]+ flakeNotFound =+ [hamlet|+ <div class="notification is-danger">+ Flake #{fu} is not found+ |]+ flakeUrlRow flakeUrl =+ [hamlet|+ <tr>+ <td>+ Link+ <td>+ <a href="#{linkUrl flakeUrl}">+ #{flakeUrl}+ |]+ flakeToWidget f =+ case f of+ SubmittedFlake { flakeUrl, submittedAt, submittedFrom } ->+ [hamlet|+ ^{flakeNotIndexed}+ <table class=table>+ <tbody class=content>+ ^{flakeUrlRow flakeUrl}+ <tr>+ <td>+ Status+ <td>+ <details>+ <summary class="notification is-info p-1">+ Submitted+ <p>The request for flake publication is accepted.+ ^{flakeStatusLifeCycle}+ <tr>+ <td>+ Timestamp+ <td>+ #{Ts submittedAt}+ <tr>+ <td>+ From+ <td>+ #{submittedFrom}+ |]+ FlakeIsBeingFetched { flakeUrl, submitionFetchedAt, fetcherId } ->+ [hamlet|+ ^{flakeNotIndexed}+ <table class=table>+ <tbody class=content>+ ^{flakeUrlRow flakeUrl}+ <tr>+ <td>+ Status+ <td>+ <details>+ <summary class="notification is-info p-1">+ On Fetcher+ <p>+ Fetcher (Worker) picked up the request+ for downloadand evaluation of the flake.+ ^{flakeStatusLifeCycle}+ <tr>+ <td>+ Timestamp+ <td>+ #{Ts submitionFetchedAt}+ <tr>+ <td>+ Fetcher+ <td>+ #{fetcherId}+ |]+ BadFlake { flakeUrl, fetcherRespondedAt, error } ->+ [hamlet|+ <table class=table>+ <tbody class=content>+ ^{flakeUrlRow flakeUrl}+ <tr>+ <td>+ Status+ <td>+ <details>+ <summary class="notification is-danger p-1">+ Rejected+ <p>Fetcher failed to obtain flake or parse it.+ <p>Check flake url and error message.+ <p>Fix the issue and resubmit the flake for publication.+ <tr>+ <td>+ Timestamp+ <td>+ #{Ts fetcherRespondedAt}+ <tr>+ <td>+ Error+ <td class="notification is-danger">+ <pre class=error>#{error}+ |]+ FlakeFetched { flakeUrl, uploadedAt, meta} ->+ let+ ps :: [PackageInfo] = concatMap elems (elems meta.packages)+ in+ [hamlet|+ ^{flakeNotIndexed}+ <table class=table>+ <tbody class=content>+ ^{flakeUrlRow flakeUrl}+ <tr>+ <td>+ Status+ <td>+ <details>+ <summary class="notification is-warning p-1">+ Fetched+ <p>Information about the flake is fetched by fetcher+ and uploaded into database for consequent indexing.+ <tr>+ <td>+ Timestamp+ <td>+ #{Ts uploadedAt}+ <h6 class="title is-6 mb-3">+ Packages+ $if null ps+ <div class="notification is-warning">+ No packages+ $else+ $forall p <- ps+ ^{packageInfoToWidget p}+ |]+ FlakeIndexed { flakeUrl, indexedAt, meta} ->+ let+ ps :: [PackageInfo] = concatMap elems (elems meta.packages)+ in+ [hamlet|+ <table class=table>+ <tbody class=content>+ ^{flakeUrlRow flakeUrl}+ <tr>+ <td>+ Status+ <td>+ <details>+ <summary class="notification is-success p-1">+ Indexed+ <p>+ The flake has been indexed and now is discoverable by search+ <tr>+ <td>+ Timestamp+ <td>+ #{Ts indexedAt}+ <tr>+ <td>+ Revision+ <td>+ #{meta.rev}+ <tr>+ <td>+ Description+ <td>+ #{fromMaybe "n/a" meta.description}++ <h4 class="title is-4 mb-3">+ Packages+ $if null ps+ <div class="notification is-warning">+ No packages+ $else+ $forall p <- ps+ ^{packageInfoToWidget p}+ |]++packageInfoToWidget :: PackageInfo -> p -> MarkupM ()+packageInfoToWidget (pi :: PackageInfo) =+ [hamlet|+ <details>+ <summary>+ Package #{pi.name}+ <table class="table">+ $forall d <- pi.description+ <tr>+ <td>Description+ <td>#{d}+ $forall l <- pi.license+ <tr>+ <td>License+ <td>#{l}+ $forall uf <- pi.unfree+ <tr>+ <td>Unfree+ <td>#{uf}+ $forall b <- pi.broken+ <tr>+ <td>Broken+ <td>#{b}+ |]++navBar :: WidgetFor Ypp ()+navBar = do+ Ypp { repo } <- getYesod+ toWidgetBody [hamlet|+ <nav class=navbar role=navigation aria-label="main navigation">+ <div class=navbar-brand>+ <a class="navbar-item has-text-weight-bold"+ href=#{untag repo.wsArgs.baseUrl}>+ <img src="/favicon.svg">+ A Piece of Flake+ <a role=button aria-label=menu aria-expanded=false+ data-target=navbar-menu id=navbar-burger class=navbar-burger>+ <span aria-hidden="true">+ <span aria-hidden="true">+ <span aria-hidden="true">+ <span aria-hidden="true">+ <div id=navbar-menu class=navbar-menu>+ <div class="navbar-start">+ <a class="navbar-item" href=@{PublicationR}>+ Publish+ <a class="navbar-item" href=@{SearchR}>+ Search+ <a class="navbar-item" href=@{StatsR}>+ Stats+ <a class="navbar-item" href=@{AboutR}>+ About+ |]++postSubmitFlakeR :: Handler Flake+postSubmitFlakeR = do+ requireCheckJsonBody >>= \rfu ->+ case validateRawFlakeUrl rfu of+ Nothing -> invalidArgs ["flake url formatting is wrong"]+ Just fu -> do+ ip <- IpAdr . clientAdrToDec4 <$> getClientAdr+ Ypp { repo } <- getYesod+ trySubmitFlakeToRepo ip repo fu >>= \case+ Left e -> invalidArgs [e]+ Right f -> pure f++postFetchNewFlakeSubmitionsR :: Handler (Maybe FlakeUrl)+postFetchNewFlakeSubmitionsR = do+ Ypp { repo } <- getYesod+ requireCheckJsonBody >>= \case+ FetcherReq fetcherId Nothing fsec -> verifyFetcher fsec $ do+ $(logDebug) "Just Fetch next FlakeUrl"+ r <- popFlakeSubmition repo fetcherId+ $(logDebug) $ show $ encode r <> " :: Maybe Flake <-> " <> show r+ pure r+ FetcherReq fetcherId (Just fetchedFlake) fsec -> verifyFetcher fsec $ do+ $(logInfo) $ "Fetcher returned " <> show fetchedFlake+ addFetchedFlake repo fetcherId fetchedFlake++verifyFetcher :: FetcherSecret -> Handler a -> Handler a+verifyFetcher gotFsec a = do+ Ypp { repo } <- getYesod+ if gotFsec == repo.fetcherSecret+ then a+ else permissionDenied "secret mismatch"++postFindFlakesR :: Handler [ FlakeUrl ]+postFindFlakesR = do+ Ypp { repo } <- getYesod+ requireCheckJsonBody >>= go repo+ where+ go repo = findFlakes repo.repoStats repo.flakes repo.flakeIndex++postFetcherHeartbeatR :: Handler Unit+postFetcherHeartbeatR = do+ Ypp { repo } <- getYesod+ requireCheckJsonBody >>= \fhb ->+ verifyFetcher fhb.fetcherSecret (fetcherIsAlive repo fhb >> pure Unit)++getFetcherAutoConfigR :: Handler FetcherAutoConfig+getFetcherAutoConfigR = do+ Ypp { repo } <- getYesod+ pure FetcherAutoConfig+ { heartbeatPeriod = Period <$> repo.wsArgs.fetcherHeartbeatPeriod+ , httpMinTimeout = Period <$> repo.wsArgs.noSubmitionHeartbeat+ }
@@ -0,0 +1,45 @@+module PieceOfFlake.Prelude+ ( module X+ , readFileTxt+ , toMs+ , PoF+ , alt+ , threadDelay+ , toNominal+ ) where++import Control.Concurrent qualified as CC+import Control.Exception as X (IOException)+import Control.Lens as X ((^.), _2, _1)+import Control.Monad.Catch as X (Handler (..))+import Control.Monad.Logger as X++import Data.Aeson as X ( FromJSON, ToJSON )+import Data.Tagged as X+import Data.Time.Clock.NonNegativeTimeDiff as X+import Data.Time.Units as X+import Debug.TraceEmbrace as X hiding (PackageName, Error, a, u)+import GHC.TypeLits as X (symbolVal)+import Refined as X (unrefine, refine, refineTH, Refined, FromTo)+import Relude as X hiding (Handle, intercalate)+import System.IO.Unsafe as X (unsafePerformIO)+import UnliftIO as X (MonadUnliftIO, finally, catchAny, catch, stringException, throwIO)++readFileTxt :: MonadIO m => FilePath -> m Text+readFileTxt fp = decodeUtf8 <$> readFileBS fp++toMs :: TimeUnit a => a -> Int+toMs = fromIntegral . toMicroseconds++type PoF m = (MonadLogger m, MonadIO m, MonadUnliftIO m, ClockMonad m)+++alt :: [a] -> [a] -> [a]+alt a b = case a of [] -> b ; o -> o++threadDelay :: (MonadIO m, TimeUnit tu) => tu -> m ()+threadDelay d =+ liftIO $ CC.threadDelay $ fromIntegral (convertUnit d :: Microsecond)++toNominal :: TimeUnit tu => tu -> NominalDiffTime+toNominal tu = (fromIntegral (convertUnit tu :: Microsecond) :: NominalDiffTime) / 1_000_000
@@ -0,0 +1,78 @@+{-# LANGUAGE OverloadedRecordDot #-}+module PieceOfFlake.Req+ ( module PieceOfFlake.Req+ , module Network.HTTP.Req+ )+where++import Network.HTTP.Req+import PieceOfFlake.Prelude as P+import Text.Regex.TDFA ( AllTextSubmatches(getAllTextSubmatches), (=~) )+import Text.Show as S++urlRegex :: Text+urlRegex = "^(http|https)://([a-z0-9._-]+)(:([1-9][0-9]*))?/?$"++data DynamicUrl+ = UrlHttp+ { duu :: Url 'Http+ , duo :: Option 'Http+ }+ | UrlHttps+ { duus :: Url 'Https+ , duos :: Option 'Https+ }++instance Show DynamicUrl where+ show (UrlHttp ur o) = "http://" <> S.show ur <> S.show (queryParamToList o)+ show (UrlHttps ur o) = "https://" <> S.show ur <> S.show (queryParamToList o)+++secToTimeout :: Second -> Option a+secToTimeout = responseTimeout . fromIntegral . toMicroseconds++setResponseTimeout :: DynamicUrl -> Second -> DynamicUrl+setResponseTimeout a@UrlHttp {} s = a { duo = a.duo <> secToTimeout s }+setResponseTimeout a@UrlHttps {} s = a { duos = a.duos <> secToTimeout s }++parseUrl :: Text -> Either Text DynamicUrl+parseUrl ur =+ case getAllTextSubmatches (ur =~ urlRegex) of+ ([_full, protocolG, domainG ] :: [Text]) ->+ case protocolG of+ "http" -> pure $ UrlHttp (http domainG) mempty+ "https" -> pure $ UrlHttps (https domainG) mempty+ _ -> Left $ "Bad protocol: " <> protocolG+ ([_full, protocolG, domainG, _colon, portG ] :: [Text]) ->+ case readMaybe $ toString portG of+ Nothing -> Left $ "Bad port " <> portG+ Just p+ | p > 0 && p < 65123 ->+ case protocolG of+ "http" -> pure . UrlHttp (http domainG) $ port p+ "https" -> pure . UrlHttps (https domainG) $ port p+ _ -> P.error $ "Bad protocol: " <> protocolG+ | otherwise ->+ Left $ "Bad port: " <> P.show p+ _badUrl -> Left $ "Bad service url: " <> P.show ur++dynReq :: forall m method body response.+ ( MonadHttp m+ , HttpMethod method+ , HttpBody body+ , HttpResponse response+ , HttpBodyAllowed (AllowsBody method) (ProvidesBody body)) =>+ method -> DynamicUrl -> Text -> body -> Proxy response -> m response+dynReq m dUrl path = dynReq' m dUrl (/: path)++dynReq' :: forall m method body response.+ ( MonadHttp m+ , HttpMethod method+ , HttpBody body+ , HttpResponse response+ , HttpBodyAllowed (AllowsBody method) (ProvidesBody body)) =>+ method -> DynamicUrl -> (forall u. Url u -> Url u) -> body -> Proxy response -> m response+dynReq' m dUrl pathF body pro =+ case dUrl of+ UrlHttp ur o -> req m (pathF ur) body pro o+ UrlHttps ur o -> req m (pathF ur) body pro o
@@ -0,0 +1,142 @@+{-# LANGUAGE DerivingVia #-}+{-# LANGUAGE QuasiQuotes #-}+module PieceOfFlake.Stats where++import Data.RingBuffer as RB ( RingBuffer, append, new, toList )+import Data.Vector as V ( Vector, fromList )+import Generics.SOP as S+import PieceOfFlake.CmdArgs ( RingBufferSize(..) )+import PieceOfFlake.Prelude+import PieceOfFlake.Prelude qualified as P+import Statistics.Sample as SS ( mean )+import Text.Blaze.Internal ( MarkupM )+import Text.Blaze ( ToMarkup(toMarkup) )+import Yesod.Core ( hamlet )++class ReadTVar a b where+ readTraVar :: MonadIO m => a -> m b++greadTraVar :: (MonadIO m, S.Generic a, S.Generic b, AllZip2 ReadTVar (Code a) (Code b)) => a -> m b+greadTraVar x = to <$> greadTraVarS (from x)++greadTraVarS :: (MonadIO m, AllZip2 ReadTVar xss yss) => SOP I xss -> m (SOP I yss)+greadTraVarS (SOP (Z xs)) = SOP . Z <$> greadTraVarP xs+greadTraVarS (SOP (S xss)) = do+ SOP r <- greadTraVarS (SOP xss)+ pure $ SOP (S r)++greadTraVarP :: (MonadIO m, AllZip ReadTVar xs ys) => NP I xs -> m (NP I ys)+greadTraVarP Nil = pure Nil+greadTraVarP (I x :* xs) = do+ r <- readTraVar x+ (I r :*) <$> greadTraVarP xs++++type family Columnar (f :: Type -> Type) (g :: Type -> Type) a+type instance Columnar TVar TVar a = TVar a+type instance Columnar Ydentity TVar a = Ydentity a++type instance Columnar TVar (RingBuffer Vector) NominalDiffTime = RingBuffer Vector NominalDiffTime+type instance Columnar Ydentity (RingBuffer Vector) NominalDiffTime = Double++data RepoStatsF f+ = RepoStats+ { totalFlakeUploadsSinceRestart :: Columnar f TVar Int+ , meanFetchTime :: Columnar f (RingBuffer Vector) NominalDiffTime+ , meanIndexTime :: Columnar f (RingBuffer Vector) NominalDiffTime+ , meanTimeInFetchQueue :: Columnar f (RingBuffer Vector) NominalDiffTime+ , meanTimeInIndexQueue :: Columnar f (RingBuffer Vector) NominalDiffTime+ , meanSearchTime :: Columnar f (RingBuffer Vector) NominalDiffTime+ , submittedFlakes :: Columnar f TVar Int+ , fetchingFlakes :: Columnar f TVar Int+ , badFlakes :: Columnar f TVar Int+ , fetchedFlakes :: Columnar f TVar Int+ , indexedFlakes :: Columnar f TVar Int+ } deriving P.Generic+instance S.Generic (RepoStatsF a)++mkRepoStats :: MonadIO m => RingBufferSize -> m (RepoStatsF TVar)+mkRepoStats (unrefine . coerce -> rbs) =+ liftIO $+ RepoStats <$>+ newTVarIO 0 <*>+ RB.new rbs <*>+ RB.new rbs <*>+ RB.new rbs <*>+ RB.new rbs <*>+ RB.new rbs <*>+ newTVarIO 0 <*>+ newTVarIO 0 <*>+ newTVarIO 0 <*>+ newTVarIO 0 <*>+ newTVarIO 0++newtype Ydentity x = Ydentity { runYdentity :: x } deriving (Functor, Applicative, Monad) via Identity++instance ToMarkup a => ToMarkup (Ydentity a) where+ toMarkup = toMarkup . runYdentity++instance ReadTVar (TVar a) (Ydentity a) where+ readTraVar x = do+ r <- readTVarIO x+ pure (Ydentity r)++instance ReadTVar (RingBuffer Vector NominalDiffTime) Double where+ readTraVar = meanFetch++meanFetch :: MonadIO m => RingBuffer Vector NominalDiffTime -> m Double+meanFetch rb = SS.mean . V.fromList . fmap realToFrac <$> liftIO (RB.toList rb)++addTimeDif :: MonadIO m => RingBuffer Vector NominalDiffTime -> NominalDiffTime -> m ()+addTimeDif rb td = liftIO $ RB.append td rb++type RepoStats = RepoStatsF Ydentity++renderRepoStats :: Integer -> Int -> Tagged "fetch" Int -> RepoStatsF Ydentity -> p -> MarkupM ()+renderRepoStats searchRequests idxQueueLen (Tagged fetchQueueLen) rs =+ [hamlet|+ <h1 class="title is-4 mb-3">+ Repository stats+ <table class="table is-bordered">+ <tbody>+ <tr>+ <td>Search requests+ <td>#{searchRequests}+ <tr>+ <td>Mean search time+ <td>#{rs.meanSearchTime}+ <tr>+ <td>Submitted Flakes+ <td>#{rs.submittedFlakes}+ <tr>+ <td>Fetching Flakes+ <td>#{rs.fetchingFlakes}+ <tr>+ <td>Bad Flakes+ <td>#{rs.badFlakes}+ <tr>+ <td>Fetched Flakes+ <td>#{rs.fetchedFlakes}+ <tr>+ <td>Indexed Flakes+ <td>#{rs.indexedFlakes}+ <tr>+ <td>Fetch Queue+ <td>#{fetchQueueLen}+ <tr>+ <td>Index Queue+ <td>#{idxQueueLen}+ <tr>+ <td>Flake Fetch mean time+ <td>#{rs.meanFetchTime}+ <tr>+ <td>Flake Index mean time+ <td>#{rs.meanIndexTime}+ <tr>+ <td>Mean time flake waits in fetch queue+ <td>#{rs.meanTimeInFetchQueue}+ <tr>+ <td>Mean time flake waits in index queue+ <td>#{rs.meanTimeInIndexQueue}+ |]
@@ -0,0 +1,16 @@+module PieceOfFlake.Stm+ ( module Control.Concurrent.STM.TQueue+ , atomicalog+ ) where+++import Control.Concurrent.STM.TQueue+import PieceOfFlake.Prelude hiding (Map)++atomicalog :: PoF m => WriterLoggingT STM a -> m a+atomicalog a = do+ (r, l) <- atomically (runWriterLoggingT a)+ mapM_ (unc4 monadLoggerLog) l+ pure r+ where+ unc4 f (a1, b1, c1, d1) = f a1 b1 c1 d1
@@ -0,0 +1,61 @@+module PieceOfFlake.SubmitList where+++import PieceOfFlake.CmdArgs+ ( SubmitListOfFlakesArgs(indexTimeoutIn, webServiceUrl) )+import PieceOfFlake.Prelude as P+import PieceOfFlake.Flake+ ( FlakeUrl(unFlakeUrl), Flake(flakeUrl), RawFlakeUrl(RawFlakeUrl) )+import PieceOfFlake.Req+ ( POST(POST),+ ReqBodyJson(ReqBodyJson),+ defaultHttpConfig,+ jsonResponse,+ responseBody,+ runReq,+ dynReq,+ (/:),+ bsResponse,+ GET(GET),+ NoReqBody(NoReqBody),+ dynReq' )+import System.IO (hPutChar, hPutStrLn)+import UnliftIO.Exception ( catchIO )+data IndexTimeout = IndexTimeout deriving (Show, Eq)+instance Exception IndexTimeout++runSubmitList :: PoF m => SubmitListOfFlakesArgs -> m ()+runSubmitList sla = liftIO go+ where+ htp = runReq defaultHttpConfig+ waitUntilIndexed startedAt fu = do+ br <- htp $ dynReq' GET sla.webServiceUrl (\ur -> ur /: "flake" /: "status" /: unFlakeUrl fu) NoReqBody bsResponse+ case responseBody br of+ "Indexed" -> pr $ "OK " <> unFlakeUrl fu+ "Bad" -> pr $ "BAD " <> unFlakeUrl fu+ "NotFound" -> pr $ "WSER " <> unFlakeUrl fu+ _o -> do+ _ <- hPutChar stderr '.'+ P.hFlush stderr+ threadDelay (1 :: Second)+ now <- getTimeAfter startedAt+ if now `diffUTCTime` startedAt > untag sla.indexTimeoutIn then+ pr $ "TIMEOUT " <> unFlakeUrl fu+ else+ waitUntilIndexed startedAt fu+ erLn = void (hPutChar stderr '\n')+ pr msg = void (erLn >> putTextLn msg)+ go = catchIO getLine (\_e -> pure "") >>= \case+ "" -> pure ()+ l -> do+ _ <- hPutStrLn stderr $ "Indexing " <> toString l+ let rjb = ReqBodyJson $ RawFlakeUrl l+ jr <- htp $ dynReq POST sla.webServiceUrl "submit-flake" rjb jsonResponse+ case responseBody jr of+ Nothing -> do+ pr $ "FAIL-TO-SUBMIT " <> l+ go+ Just (f :: Flake) -> do+ now <- getCurrentTime+ waitUntilIndexed now f.flakeUrl+ go
@@ -0,0 +1,12 @@+module PieceOfFlake.Th where++import AddDependentFile ( addDependentFile, getPackageRoot, (</>) )+import Data.FileEmbed ( embedFile )+import Language.Haskell.TH.Syntax ( Exp, Q )+import PieceOfFlake.Prelude ( (<$>), FilePath )++includeFile :: FilePath -> Q Exp+includeFile p = do+ ap <- (</> p) <$> getPackageRoot+ addDependentFile ap+ embedFile ap
@@ -0,0 +1,15 @@+module PieceOfFlake.TotalMath (Nz, notZero, divNz, realToFracNz) where++import Prelude++newtype Nz a = Nz a++notZero :: (Eq a, Num a) => a -> Maybe (Nz a)+notZero 0 = Nothing+notZero x = pure $ Nz x++divNz :: Fractional a => a -> Nz a -> a+divNz a (Nz b) = a / b++realToFracNz :: (Real a, Fractional b) => Nz a -> Nz b+realToFracNz (Nz x) = Nz $ realToFrac x
@@ -0,0 +1,49 @@+module PieceOfFlake.WebService where++import Data.Aeson ( FromJSON(parseJSON), ToJSON(toJSON), encode )+import PieceOfFlake.Flake ( FlakeUrl, FetcherId )+import PieceOfFlake.Prelude+import Text.Show ( Show(show) )+import Yesod.Core+ ( ToContent(..),+ ToTypedContent(..),+ typeJson,+ TypedContent(TypedContent) )+++newtype FetcherSecret = FetcherSecret Text deriving (Eq, Generic, FromJSON, ToJSON)+instance Show FetcherSecret where+ show _ = "****"++data FetcherHeartbeat+ = FetcherHeartbeat+ { fetcherSecret :: FetcherSecret+ , fetcherId :: FetcherId+ , workingOn :: FlakeUrl+ } deriving (Show, Eq, Generic)++instance FromJSON FetcherHeartbeat+instance ToJSON FetcherHeartbeat++newtype Period = Period { unPeriod :: Second } deriving newtype (Show, Eq, Ord, Num)++instance FromJSON Period where+ parseJSON x = Period . fromMicroseconds <$> parseJSON x+instance ToJSON Period where+ toJSON = toJSON . toMicroseconds . unPeriod++data FetcherHeartbeatPeriod+data NoSubmitionHeartbeatSec+data FetcherAutoConfig+ = FetcherAutoConfig+ { heartbeatPeriod :: Tagged FetcherHeartbeatPeriod Period+ , httpMinTimeout :: Tagged NoSubmitionHeartbeatSec Period+ }+ deriving (Show, Eq, Generic)++instance FromJSON FetcherAutoConfig+instance ToJSON FetcherAutoConfig+instance ToContent FetcherAutoConfig where+ toContent = toContent . encode+instance ToTypedContent FetcherAutoConfig where+ toTypedContent = TypedContent typeJson . toContent
@@ -0,0 +1,205 @@+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TypeFamilies #-}+module PieceOfFlake.Yesod where++import Data.Binary.Builder (fromByteString)+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as B8+import Data.Map.Strict qualified as M+import Data.Text (intercalate)+import GHC.Records ( HasField )+import Network.HTTP.Types qualified as H+import Network.HTTP.Types.Header ( hAcceptEncoding )+import Network.Socket (SockAddr(SockAddrInet), hostAddressToTuple, tupleToHostAddress, HostAddress)+import Network.Wai ( Request(remoteHost) )+import PieceOfFlake.CmdArgs ( StaticCacheSeconds )+import PieceOfFlake.Prelude+import Text.Blaze ( ToMarkup(toMarkup) )+import Text.Show qualified as TS+import Text.Regex.TDFA ( AllTextSubmatches(getAllTextSubmatches), (=~) )+import Yesod.Core+ ( hamlet,+ getYesod,+ cacheSeconds,+ widgetToPageContent,+ typeSvg,+ getMessages,+ sendResponseStatus,+ waiRequest,+ withUrlRenderer,+ Html,+ Yesod,+ ToContent(..),+ ToTypedContent(..),+ Content(ContentBuilder),+ HandlerFor,+ PageContent(pageBody, pageTitle, pageDescription, pageHead),+ TypedContent(TypedContent),+ WidgetFor, lookupHeader, addHeader, typeJson )+++newtype HostIp = HostIp HostAddress deriving newtype (Eq, Ord)+instance Show HostIp where+ show = toString . hostIpToDec+instance IsString HostIp where+ fromString s =+ fromMaybe (error . toText $ "Failed to parse [" <> s <> "] as IPv4 address") $ parseIpV4 s++newtype ClientAdr = ClientAdr HostIp deriving (Eq, Ord)++instance Show ClientAdr where+ show = show . clientAdrToDec4++clientAdrToDec4 :: ClientAdr -> Text+clientAdrToDec4 (ClientAdr sa) = hostIpToDec sa++getClientAdr :: HandlerFor a ClientAdr+getClientAdr =+ waiRequest >>= \r ->+ case remoteHost r of+ SockAddrInet _port hip -> pure . ClientAdr $ HostIp hip+ _ -> throwIO $ stringException "Unsupported socket addr"++parseIpV4 :: String -> Maybe HostIp+parseIpV4 s =+ case getAllTextSubmatches (s =~ ipPat) of+ [_full, a, b, c, d] ->+ case readEither a of+ Left _ -> Nothing+ Right ai ->+ case readEither b of+ Left _ -> Nothing+ Right bi ->+ case readEither c of+ Left _ -> Nothing+ Right ci ->+ case readEither d of+ Left _ -> Nothing+ Right di ->+ pure . HostIp $ tupleToHostAddress (ai, bi, ci, di)+ _ -> Nothing+ where+ ipPat :: String = "([[:digit:]]+)[.]([[:digit:]]+)[.]([[:digit:]]+)[.]([[:digit:]]+)"++hostIpToDec :: HostIp -> Text+hostIpToDec (HostIp hip) =+ case hostAddressToTuple hip of+ (a, b, c, d) -> intercalate "." $ fmap show [a, b, c, d]+++internalError :: Text -> HandlerFor y a+internalError =+ sendResponseStatus H.status503+++data Contentable = forall x. (ToContent x, ToTypedContent x) => Contentable x+instance ToContent Contentable where+ toContent (Contentable x) = toContent x++instance ToTypedContent Contentable where+ toTypedContent (Contentable x) = toTypedContent x++newtype FavIcon = FavIcon ByteString++instance ToContent FavIcon where+ toContent (FavIcon bs) =+ ContentBuilder (fromByteString bs) (Just . fromIntegral $ BS.length bs)+instance ToTypedContent FavIcon where+ toTypedContent = TypedContent typeSvg . toContent++mp3Mime :: Mime+mp3Mime = Mime "audio/mpeg"++bulmaLayout :: Yesod site => WidgetFor site () -> HandlerFor site Html+bulmaLayout w = do+ p <- widgetToPageContent w+ msgs <- getMessages+ withUrlRenderer+ [hamlet|+ $newline never+ $doctype 5+ <html>+ <head>+ <title>#{pageTitle p}+ $maybe description <- pageDescription p+ <meta name="description" content="#{description}">+ ^{pageHead p}+ <body>+ $forall (status, msg) <- msgs+ <p class="message #{status}">#{msg}+ ^{pageBody p}+ |]++newtype Ts = Ts { unTs :: UtcBox } deriving newtype (Show, Eq, Ord)++instance ToMarkup Ts where+ toMarkup = toMarkup . show @Text . unTs++type HasCacheField y = (HasField "staticCache" y (Tagged StaticCacheSeconds Word32), Yesod y)+setCacheHeaderForStatic :: HasCacheField y => HandlerFor y ()+setCacheHeaderForStatic = do+ y <- getYesod+ cacheSeconds . fromIntegral $ untag y.staticCache++newtype Mime = Mime ByteString++sendStaticBs :: (HasCacheField y, ToContent a) => Mime -> a -> HandlerFor y TypedContent+sendStaticBs (Mime mime) c = do+ setCacheHeaderForStatic+ pure . TypedContent mime $ toContent c++data ContentEncoding = Gzip | Br deriving (Show, Eq, Ord)++contentEncodingToHeaderValue :: ContentEncoding -> Text+contentEncodingToHeaderValue = \case+ Gzip -> "gzip"+ Br -> "br"++-- to customize Accpet-Encoding in Firefox:+-- open tab about:config -> network.http.accept-encoding.secure+parseContentEncoding :: Monad m => ByteString -> (Maybe ContentEncoding -> m a) -> m a+parseContentEncoding ce cb = go Nothing "" ce+ where+ go bestMatch en bs =+ case B8.uncons bs of+ Nothing ->+ case en of+ "gzip" -> cb $ bestMatch <|> pure Gzip+ "br" -> cb $ pure Br+ _ -> cb bestMatch+ Just (',', bs') ->+ case en of+ "gzip" -> go (Just Gzip) "" (B8.drop 1 bs')+ "br" -> cb $ pure Br+ _ -> go bestMatch "" (B8.drop 1 bs')+ Just (c, bs') ->+ go bestMatch (en `B8.snoc` c) bs'++staticFile :: (ToContent a, HasCacheField y) => Mime -> a -> Map ContentEncoding a -> HandlerFor y TypedContent+staticFile mime plainContent preEncodedContent = do+ lookupHeader hAcceptEncoding >>= \case+ Nothing -> sendStaticBs mime plainContent+ Just ae -> parseContentEncoding ae $ \case+ Nothing -> sendStaticBs mime plainContent+ Just ce ->+ case M.lookup ce preEncodedContent of+ Nothing -> sendStaticBs mime plainContent+ Just cnt -> do+ addHeader "Content-Encoding" $ contentEncodingToHeaderValue ce+ sendStaticBs mime cnt+ -- addHeader :: MonadHandler m => Text -> Text -> m ()+ -- replaceOrAddHeader :: MonadHandler m => Text -> Text -> m ()+ -- lookupHeader :: MonadHandler m => CI ByteString -> m (Maybe ByteString)+ -- get supported encodings+ -- pick best+ -- set Content-Encoding header+++-- | Handler () - is encoded as an empty string even with header Accepted equals to "application/json"+-- meanwhile Aeson.encode () = "[]" and therefore Aeson decode expect the same+data Unit = Unit deriving (Show, Eq)++instance ToContent Unit where+ toContent _ = toContent ("[]" :: ByteString)+instance ToTypedContent Unit where+ toTypedContent = TypedContent typeJson . toContent
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --generated-module=Discovery #-}
@@ -0,0 +1,13 @@+module Driver where++import qualified Discovery+import Relude+import Test.Tasty++main :: IO ()+main = defaultMain =<< testTree+ where+ testTree :: IO TestTree+ testTree = do+ tests <- Discovery.tests+ pure $ testGroup "literal-flake-input" [ tests ]
@@ -0,0 +1,23 @@+{-# LANGUAGE MultilineStrings #-}+module PieceOfFlake.Test.Aeson where++import Data.Aeson ( decode )+import PieceOfFlake.Aeson ( Some(..) )+import PieceOfFlake.Prelude+import Test.Tasty ( TestTree, testGroup )+import Test.Tasty.HUnit ( testCase, (@?=) )+++data A = A {b :: Bool, a :: Some Int } deriving (Show, Eq, Generic)+instance FromJSON A++test_e :: TestTree+test_e =+ testGroup "Aeson"+ [ testGroup "Some"+ [ go """{"b":true, "a":1}""" (Just (A True $ Atom 1))+ , go """{"b":false, "a":[2, 3]}""" (Just (A False $ More [2, 3]))+ ]+ ]+ where+ go s e = testCase (show s) $ decode s @?= e
@@ -0,0 +1,23 @@+levels:+- '-'+mode:+ # sink:+ # tag: StdErrSink+ # tag: TraceUnsafeIo+ # tag: TraceDisabled+runtimeLevelsOverrideEnvVar:+ tag: CapsPackageName+traceMessage:+ entrySeparator: '; '+ keyValueSeparator: ': '+ retValPrefix: ' => '+ traceLinePattern:+ - tag: FullyQualifiedModule+ - contents: '::'+ tag: Delimiter+ # - tag: FunctionName+ - contents: ': '+ tag: Delimiter+ - tag: LiteralMessage+ - tag: Variables+version: 1