packages feed

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 view
@@ -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.
+ a-piece-of-flake.cabal view
@@ -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
+ app/PieceOfFlake.hs view
@@ -0,0 +1,8 @@+module Main where++import PieceOfFlake.CmdArgs+import PieceOfFlake.CmdRun+import PieceOfFlake.Prelude++main :: IO ()+main = execWithArgs runCmd =<< getArgs
+ assets/app.js view
@@ -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;+};
+ assets/app.js.br view

binary file changed (absent → 1799 bytes)

+ assets/app.js.gz view

binary file changed (absent → 1975 bytes)

+ assets/avalanche.mp3 view

binary file changed (absent → 22617 bytes)

+ assets/bulma.min.css view
@@ -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}
+ assets/bulma.min.css.br view

binary file changed (absent → 5464 bytes)

+ assets/bulma.min.css.gz view

binary file changed (absent → 6084 bytes)

+ assets/favicon.svg view
@@ -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>
+ assets/favicon.svg.br view

binary file changed (absent → 2346 bytes)

+ assets/favicon.svg.gz view

binary file changed (absent → 2528 bytes)

+ assets/flake.svg view
@@ -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>
+ assets/flake.svg.br view

binary file changed (absent → 2067 bytes)

+ assets/flake.svg.gz view

binary file changed (absent → 2230 bytes)

+ assets/flush.mp3 view

binary file changed (absent → 24576 bytes)

+ assets/github.svg view
@@ -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>
+ assets/github.svg.br view

binary file changed (absent → 1683 bytes)

+ assets/github.svg.gz view

binary file changed (absent → 1774 bytes)

+ assets/robots.txt view
@@ -0,0 +1,5 @@+User-agent: *+Allow: /index.html$+Allow: /$+Disallow: /+Sitemap: https://pieceofflakenixrepository.org/sitemap.xml
+ assets/sitemap.xml view
@@ -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>
+ assets/snow.mp3 view

binary file changed (absent → 33349 bytes)

+ assets/style.css view
@@ -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;+}
+ assets/style.css.br view

binary file changed (absent → 799 bytes)

+ assets/style.css.gz view

binary file changed (absent → 894 bytes)

+ changelog.md view
@@ -0,0 +1,4 @@+# a-piece-of-flake changelog++## Version 0.0.1 2026-02-26+  * init
+ src/PieceOfFlake/Acid.hs view
@@ -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))
+ src/PieceOfFlake/Aeson.hs view
@@ -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
+ src/PieceOfFlake/CmdArgs.hs view
@@ -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"+  )
+ src/PieceOfFlake/CmdRun.hs view
@@ -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
+ src/PieceOfFlake/Fetcher.hs view
@@ -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
+ src/PieceOfFlake/Flake.hs view
@@ -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
+ src/PieceOfFlake/Flake/Repo.hs view
@@ -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
+ src/PieceOfFlake/Http.hs view
@@ -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
+ src/PieceOfFlake/Index.hs view
@@ -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))
+ src/PieceOfFlake/Page.hs view
@@ -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+    }
+ src/PieceOfFlake/Prelude.hs view
@@ -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
+ src/PieceOfFlake/Req.hs view
@@ -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
+ src/PieceOfFlake/Stats.hs view
@@ -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}+        |]
+ src/PieceOfFlake/Stm.hs view
@@ -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
+ src/PieceOfFlake/SubmitList.hs view
@@ -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
+ src/PieceOfFlake/Th.hs view
@@ -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
+ src/PieceOfFlake/TotalMath.hs view
@@ -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
+ src/PieceOfFlake/WebService.hs view
@@ -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
+ src/PieceOfFlake/Yesod.hs view
@@ -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
+ test/Discovery.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --generated-module=Discovery #-}
+ test/Driver.hs view
@@ -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 ]
+ test/PieceOfFlake/Test/Aeson.hs view
@@ -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
+ trace-embrace.yaml view
@@ -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