diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60b30e2..81a1ffc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,9 +56,10 @@ engine is one module and a few small changes: - the domains its CLI needs (`egress`); - where the CLI keeps its login (`home`), the variable that points it there (`home_env`), and its own login command. An engine that runs on API keys or on a local model server has no login: it implements `Keyed` - instead, its `providers` (each with where to create a key and where its models are listed) and `with_key`, - which returns the engine on an account's key. The key reaches the step only as a file in `/ctx` - (`Invocation.files`), never on the command line or in the environment; + instead, its `providers` (each with where to create a key, where its models are listed and, when models.dev + has them, its id there, so that the Accounts page suggests its models) and `with_key`, which returns the + engine on an account's key. The key reaches the step only as a file in `/ctx` (`Invocation.files`), never on + the command line or in the environment; - how a step becomes a command line (`invocation`), and how the output becomes events and a result (`parse` and `result`). 2. **If the CLI can read the account's quota without calling the model,** add `quota_invocation` and @@ -66,8 +67,9 @@ engine is one module and a few small changes: engine is `Keyed`: then lastcall's ledger reads what its steps cost, so `result` must report `cost_usd`. A window that limits only some models names the model in `Window.model`. 3. **Register it** in `src/lastcall/engines/__init__.py`. -4. **Pick its models** for each tier (fast, balanced, deep) in `src/lastcall/data/tiers.json`. An engine on API - keys may have none there: each of its accounts names its models. +4. **Pick its models** for each tier (fast, balanced, deep) in `src/lastcall/data/tiers.json`, and the names that + its CLI takes under `models`, which the Accounts page suggests. An engine on API keys may have none there: + each of its accounts names its models. 5. **Install its CLI**, at a pinned version, in `runner/Dockerfile`, and add its login to `runner/login.sh`, or, for an engine with no login, a branch that says so. A test checks that every engine has one there. 6. **Regenerate `egress/filter`.** The command is in `tests/test_egress.py`. diff --git a/DESIGN.md b/DESIGN.md index 2a2e7cd..10ae170 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -272,12 +272,12 @@ accounts: - id: claude-max engine: claude-code login: cli # official login inside the account's credential volume - tiers: {fast: haiku/medium, balanced: sonnet/high, deep: opus/high} + tiers: {fast: haiku/medium, balanced: sonnet/high, deep: opus/xhigh} spend: {policy: before-reset, reserve_pct: 10} - id: chatgpt-plus engine: codex login: cli - tiers: {fast: gpt-6-luna/high, balanced: gpt-6.1-sol/high, deep: gpt-6.1-sol/high} + tiers: {fast: gpt-6-luna/medium, balanced: gpt-6.1-sol/high, deep: gpt-6.1-sol/xhigh} spend: {policy: before-reset, reserve_pct: 10} - id: openrouter # v0.5 engine: opencode @@ -301,6 +301,33 @@ goes back to the default. The data file also lists the effort levels that each e `--effort` (low to max), Codex's `model_reasoning_effort` (minimal to max) and OpenCode's `--variant`, whose levels depend on the model. More effort finds more, and spends more of the quota or the budget. +The default efforts differ by tier because the work does: `fast` is only asked for by a repository's own reviewers, +so it takes `medium`; `balanced` runs the reviewers, at `high`; and `deep` runs the judge, the implementers and the +improvement plans, where quality matters most and lastcall spends quota that would expire anyway, at `xhigh`. Both +engines' effort lists include `xhigh`. + +**Suggested models.** The model fields of that page suggest names with a ``: the owner picks one or types +any other, since the catalogs change every month. Where the names come from depends on the account: + +- An account on a CLI login gets the `models` of its engine in the data file, the names that its CLI takes: Claude + Code's aliases `haiku`, `sonnet` and `opus`, which mean the newest model of their family on the Anthropic API, + and the models that Codex's own catalog lists for choosing and has not replaced with a newer one + (`codex debug models --bundled`). `fable` is left out on purpose: `claude -p` can bill a Fable request to usage + credits without asking, and lastcall never spends paid usage. +- An account on an API key (OpenCode on OpenRouter, Anthropic or OpenAI) gets the models of its provider from + models.dev's public catalog, `https://models.dev/api.json`, where OpenCode reads its prices too. The id of the + provider there is `Provider.catalog`. The web UI asks through the same `fetch` as its other lookups, with a + `User-Agent` of its own (models.dev answers 403 to the default one) and gzip (the catalog weighs 6 MB, or 0.6 MB + packed), and waits 5 seconds at most. One request answers for every provider, and the answer stays in memory for + a day, since the Accounts page asks on every load. A failure is kept for 10 minutes: meanwhile the fields suggest + nothing and the page works as before. Of each provider it keeps at most 1000 models, the ones that a step can + run on: they call tools, have a price (the budget needs one) and are not deprecated, and their ids are ones that + an account may name. +- The form that adds an API key cannot follow the provider that it has chosen, since the page runs no script: its + model fields suggest the models of every provider together (OpenRouter writes the maker first, as in + `qwen/qwen3-coder`). +- A local model server (Ollama) gets no suggestions: the web UI cannot reach it. + **Several accounts on one project.** Different providers find different problems (section 16). A review step can therefore fan out across accounts, and one judge merges the results. @@ -461,11 +488,11 @@ multiplies the score. read. - **Container logs** (`lastcall.logs`), for apps that run in Docker on lastcall's own host, with no change to their code. None is read until the owner gives it a project: - - The settings page lists the host's containers (`lastcall logs list` in the terminal), and the owner chooses the - project of each one to read. lastcall suggests one from the container's image (`ghcr.io//`, without - its tag), else its Compose project, else its name (`-web-1`), when only one project fits. A suggestion is - read only once the owner saves it, and a no to it is remembered, so that it is not suggested again. The choices - live in the service's database. + - The settings page lists the host's containers (`lastcall logs list` in the terminal; section 13 describes the + page), and the owner chooses the project of each one to read. lastcall suggests one from the container's image + (`ghcr.io//`, without its tag), else its Compose project, else its name (`-web-1`), when only one + project fits. A suggestion is read only once the owner chooses it, and a no to it is remembered, so that it is not + suggested again. The choices live in the service's database. - A container labelled `lastcall.project=` in its compose file is read for that project, matched without regard to case, with no choice on the settings page, which shows it with its project: the choice can live with the app. The two ways work together, and the label wins over a choice. @@ -789,10 +816,10 @@ the notice sticks to the top from 48 rem up (when the window is also at least 36 clear when it scrolls to the card or to a focused control; on a phone it stays in the flow above the cards, so it never hides what it brought you to. The settings page holds the connection to an error tracker (Sentry or GlitchTip: the address, the organization and a read-only token, saved only when one request with them gets through), the container logs -(the host's containers, each with its project or the one lastcall suggests, and the label that works as well), the logs -in Loki (the connection, the label that names the sources, and the project of each, with suggestions), and the -notifications: the destinations, chosen by service with only the fields that service needs (Telegram finds the owner's -chat from the bot's recent messages) or as any Apprise URL, saved only when their test notification gets through, kept +(the host's containers, and the label that works as well), the logs in Loki (the connection, the label that names the +sources, and the sources), both lists in three parts, which the paragraphs below describe, and the notifications: the +destinations, chosen by service with only the fields that service needs (Telegram finds the owner's chat from the bot's +recent messages) or as any Apprise URL, saved only when their test notification gets through, kept in the secret store and shown with their secrets hidden; the events (a pull request opens, decisions or ideas wait, lastcall merges an approved pull request or proposes a revert, a run fails or stops early, an account has paid usage on or its quota cannot be read, and, if the owner wants, a burst starts) and their language. A problem is told once when it @@ -816,6 +843,38 @@ brings the owner back after the installation starts a reading of the suggestions Adding by an address, which asks GitHub whether the App is installed there, stays for a repository that is not in the list. +The containers of the settings page and the sources in Loki come in three parts, so that dozens of them stay short +(`_lists.html` draws both). `Read now` comes first: what lastcall reads, in compact rows. A container with the label +`lastcall.project` says that its label decides and has nothing to stop; one whose label names no project says so, and is +read once the project is added. One that the owner chose shows its project, as the Projects page writes it, with a Stop +reading button, which records the owner's no. The suggested ones, which have no label, no choice and one project that +fits, use the tiles of the Projects page's add-many form: a checkbox with the container and the project that it would be +read for, a button that reads all of them, and a bar that reads or skips the ticked ones (a skip records the no, as +`lastcall logs set -` does, so that the suggestion does not come back). Every other container or source goes +in a fold that says how many they are, closed unless a filter is set or a typed project is refused. From the seventh on, +the fold has a filter by name (`logs_q` and `loki_q` in the address, ignoring case, each keeping the other's, and the +fold opens while one is set), and each of them gets one text field whose `` holds the projects, so that the +page lists the projects once, not once in every row. A choice for a project that was removed shows apart in that fold, +as before, and no save erases it: an empty field changes nothing. lastcall's own containers, which are in its Compose +project or are named `lastcall-...`, never show, in either list. + +The forms of those parts post to `POST /settings/logs` and `POST /settings/loki/sources` (which also says the label that +names the sources: the choices belong to it). Each row is `name-N` and, for a project, `project-N`, and `pick` says what +the button asks: `selected` (the rows ticked in `tick`), `suggested` (all of them), `skip` or `stop` (a no), or, without +it, the projects typed in the fold of the others. The server asks the host and Loki nothing: it checks every project +against the projects, ignoring case, and writes it as the Projects page writes it. A project typed that is none of them +saves nothing: the section says so and gives back what was typed, with the fold open. A form that names a container or a +source that is not acceptable (one of lastcall's steps), or a suggestion for a project that does not exist, is refused +as a forged form, and a button for the ticked ones with none ticked says so. + +With more than 6 projects, or while one is set, the Projects page offers a search by name (`search`, in the whole +repository name, ignoring case) and the filter links All, Active, Inactive and Waiting for you (`show`: pull requests to +review, decisions in an issue or ideas to choose, as the dashboard lists them), each with its count for the current +search, and the current one with `aria-current`. The form and the links bring the page back to the bar (`#find`) and +keep the suggestions' filter (`q`), which in turn keeps these; a search that leaves no project says so, with a link to +show them all. Also with more than 6 projects, each card's Run now starts folded, like its Settings, so that the cards +are short: its title is the summary that opens it, and its "?" moves inside, after a line that says what it does. + The state at the top of a project's card, its chip ("active · priority ×2" or "inactive"), is a button in a small form: one click turns the project's bursts on or off (`POST /projects///active`). The form says the state that it wants and does not flip the one that it finds, so that a page that is out of date never turns the project the wrong way. diff --git a/README.md b/README.md index 09c84a3..2808cc9 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,13 @@ spending limit at the provider too. **Models and effort.** Every step asks for a tier: the reviewers for *balanced*; the judge, the implementers and the improvement plans for *deep*; your repository's own reviewers for the one they name. Each engine has a default -model and effort per tier, such as opus at `high` effort for Claude Code's *deep*. On the Accounts page, under +model and effort per tier, such as opus at `xhigh` effort for Claude Code's *deep*. The efforts differ because the +work does: *deep* runs at `xhigh` because quality matters most there and the quota would expire anyway, *balanced* at +`high`, and *fast*, which only your repository's own reviewers ask for, at `medium`. On the Accounts page, under *Models and effort*, or with `lastcall account tier`, you can change both for one account, subscriptions included. -More effort finds more, and spends more of the quota. +The Model field suggests names and takes any other you type: the ones that the engine's CLI takes or, for an API key, +the models that [models.dev](https://models.dev) lists for its provider. More effort finds more, and spends more of +the quota. The rest of lastcall only knows the engine contract. Adding another tool is one module and a few small changes: [CONTRIBUTING.md](CONTRIBUTING.md#adding-an-engine) lists them. @@ -169,6 +173,8 @@ Projects, and Settings holds a fourth that is optional: - 📁 **Projects.** lastcall suggests your repositories and says why. Tick the ones you want, or add the suggested ones or all of them at once, with a filter when you have many. Any other repository goes in by its address. A click on a project's state turns its bursts on or off, and its trash button removes it from lastcall but keeps its history. + With many projects, a search by name and the links All, Active, Inactive and Waiting for you (each with its count) + keep the list short, and each card's Run now starts folded. - 🔔 **Settings.** Where lastcall should notify you. Choose the service and fill in what it asks for: Telegram (paste your bot's token and lastcall finds your chat), ntfy, Discord, Slack or email, or any of the [services that Apprise reaches](https://github.com/caronc/apprise#supported-notifications). lastcall saves a @@ -341,10 +347,14 @@ code where they happen, and the reviewers look for their causes first. Projects first. The messages are masked before they reach a model. **Container logs.** If your apps run in Docker on the same machine as lastcall, it can read their logs too, with or -without a tracker, and with no change to their code. On the Settings page, under *Container logs*, choose the -project of each container to read, and save. lastcall lists the machine's containers and suggests a project for -each from its image (`ghcr.io/owner/name`), its Compose project or its name. It reads none until you save, and it -remembers when you say no to a suggestion. In the terminal: `lastcall logs list`, then +without a tracker, and with no change to their code. On the Settings page, under *Container logs*, lastcall lists the +machine's containers in three parts, so that dozens of them stay short. *Read now* comes first: the containers that +lastcall reads, each with its project and how it was chosen, and a button to stop reading one. Then come the suggested +ones, which lastcall matched to a project from the container's image (`ghcr.io/owner/name`), its Compose project or +its name: tick the ones to read, or read all of them at once. The rest wait in a fold that says how many they are, +with a filter by name: type a project next to a container to read it, and the list of your projects narrows as you +type. lastcall reads none until you choose it, and it remembers when you skip a suggestion or stop reading a +container. lastcall's own containers never show there. In the terminal: `lastcall logs list`, then `lastcall logs set `. You can also keep the choice in the app's compose file: label its containers with the project's name, as the @@ -371,7 +381,9 @@ collects their logs, lastcall reads them there, with nothing to install on those 1. On the Settings page, under *Logs in Loki*, give Loki's address and, if it asks for one, a login that can only read. In Grafana Cloud, that is the user number of your Loki and a token with the `logs:read` scope. 2. lastcall lists the sources that Loki saw in the last 7 days, named by their `container` label when they have one, - and suggests the project of each, as for the containers on its own machine. Choose them, and save. + in the same three parts as the containers on its own machine: *Read now*, the suggested ones, which you tick (or + read all at once), and the rest in a fold with a filter by name, where you type a project next to a source. Each + shows the machines where it ran and its Compose project. It asks Loki only for the lines of the last day that may be part of an error, the newest 5,000 of each source, and reads them as it reads a container's. In the terminal: `lastcall loki connect --url http://:3100`, @@ -387,7 +399,9 @@ in lastcall only, never in `.lastcall.yml`, so that nobody can turn them on from later run, on the current code. - **Watch after the merge.** For a day after a merge, lastcall looks at the checks of the merged commit and, if you give one, at a health address every 10 minutes. When the checks fail where they passed before, or the address - fails 3 times in a row, it opens a pull request that reverts the change, pauses the project and tells you. + fails 3 times in a row, it opens a pull request that reverts the change, pauses the project and tells you. Nothing + else takes part in the watch: container logs, Loki and your error tracker feed the audits, which use them to find + the causes of errors. Both need the GitHub App to read checks and commit statuses, which Apps created by lastcall 0.7.2 or later have; the Projects page says what to do for an older App. @@ -457,7 +471,8 @@ With a local model on Ollama, it goes to no model provider. The names and versions of your dependencies go to [osv.dev](https://osv.dev) in every audit, and to [deps.dev](https://deps.dev) for upgrades. The agents' steps reach nothing beyond the allowlist in [egress/filter](egress/filter), and errors from production are masked before they reach a model. lastcall itself -sends no telemetry. +sends no telemetry. Opening the Accounts page makes it ask [models.dev](https://models.dev), about once a day, for +the names of the models that an API key's provider offers, in a request that carries nothing of yours. ### What if a file in my repository tricks the agent? diff --git a/src/lastcall/catalog.py b/src/lastcall/catalog.py new file mode 100644 index 0000000..ce0eb5a --- /dev/null +++ b/src/lastcall/catalog.py @@ -0,0 +1,89 @@ +"""models.dev's public catalog of models (https://models.dev/api.json), where OpenCode reads what each model costs. +The web UI reads from it which models an API key's provider offers, to suggest them in the model fields +(DESIGN.md, section 6). + +The catalog is one JSON object, `{provider id: {"models": {model id: {...}}}}`. Of each model lastcall looks at three +things: `tool_call`, since a step runs on tools; `cost`, since the monthly budget counts what each step costs, so a +model with no price cannot run; and `status`, since a deprecated model is not worth suggesting. It suggests only the +ids that an account's model field takes (`accounts.MODEL`). Anything else in the catalog is ignored, and an answer +that does not look like this counts as no models. + +The answer weighs 6 MB, or 0.6 MB gzipped, so the request asks for gzip. The reading is bounded: neither the answer +nor what it unpacks to may pass `MAX_BYTES`, and a provider keeps `MAX_MODELS` models at most. +""" + +from __future__ import annotations + +import json +import urllib.request +import zlib +from collections.abc import Iterable + +from lastcall import __version__ +from lastcall.accounts import MODEL +from lastcall.github import Fetch + +URL = "https://models.dev/api.json" +AGENT = f"lastcall/{__version__} (+https://github.com/ne0x86/lastcall)" # without a User-Agent, models.dev says 403 +MAX_BYTES = 32 * 1024 * 1024 # the most that the answer may weigh, packed or unpacked +MAX_MODELS = 1000 # the most that one provider keeps: OpenRouter lists the most, about 300 + + +def request(timeout: float) -> urllib.request.Request: + req = urllib.request.Request(URL) + req.add_header("User-Agent", AGENT) + req.add_header("Accept", "application/json") + req.add_header("Accept-Encoding", "gzip") + req.timeout = timeout # read by github.urlopen; a fake fetch ignores it + return req + + +def models(fetch: Fetch, providers: Iterable[str], timeout: float) -> dict[str, tuple[str, ...]]: + """The models that models.dev lists for each of `providers`, sorted, from one request to `fetch`: the ones that a + step can run on, `MAX_MODELS` at most. A provider that the catalog does not list gets none. Raises what `fetch` + raises, or ValueError, or zlib.error, when the answer cannot be read.""" + status, headers, raw = fetch(request(timeout)) + if status != 200: + raise ValueError(f"models.dev answered {status}") + if len(raw) > MAX_BYTES: + raise ValueError("the answer of models.dev is too big") + if any(name.lower() == "content-encoding" and value.strip().lower() == "gzip" for name, value in headers.items()): + raw = gunzip(raw) + catalog = json.loads(raw) + if not isinstance(catalog, dict): + raise ValueError("models.dev did not answer with its catalog") + return {name: suggestions(catalog.get(name)) for name in providers} + + +def gunzip(packed: bytes) -> bytes: + """What a gzipped answer unpacks to. A few KB can unpack to gigabytes, so more than `MAX_BYTES` is refused.""" + inflater = zlib.decompressobj(wbits=zlib.MAX_WBITS | 16) + unpacked = inflater.decompress(packed, MAX_BYTES + 1) + if len(unpacked) > MAX_BYTES: + raise ValueError("the answer of models.dev is too big") + return unpacked + + +def suggestions(provider: object) -> tuple[str, ...]: + """The ids of the models in a provider's entry of the catalog that a step can run on, in alphabetical order.""" + listed = provider.get("models") if isinstance(provider, dict) else None + if not isinstance(listed, dict): + return () + found = [name for name, model in listed.items() if isinstance(name, str) and usable(name, model)] + return tuple(sorted(found, key=lambda name: (name.casefold(), name))[:MAX_MODELS]) + + +def usable(name: str, model: object) -> bool: + """Whether a step can run on this model and an account may name it: it calls tools, it has a price (free is a + price) and it is not deprecated.""" + if not isinstance(model, dict) or not MODEL.fullmatch(name): + return False + return model.get("tool_call") is True and model.get("status") != "deprecated" and priced(model.get("cost")) + + +def priced(cost: object) -> bool: + """Whether a cost entry of the catalog has the price of what goes in and of what comes out.""" + if not isinstance(cost, dict): + return False + prices = [cost.get("input"), cost.get("output")] + return all(isinstance(price, int | float) and not isinstance(price, bool) for price in prices) diff --git a/src/lastcall/data/tiers.json b/src/lastcall/data/tiers.json index 97873e6..4ed4d6b 100644 --- a/src/lastcall/data/tiers.json +++ b/src/lastcall/data/tiers.json @@ -1,14 +1,19 @@ { - "_about": "Default model and effort for each tier, per engine (DESIGN.md, section 6), and the effort levels that each engine's CLI takes. Catalogs change every month: update this file, not the code. An account can override any tier's model and effort.", + "_about": "Default model and effort for each tier, per engine (DESIGN.md, section 6), the model names that each engine's CLI takes and the effort levels that it takes. Catalogs change every month: update this file, not the code. An account can override any tier's model and effort. The effort follows the work: fast is only asked for by a repository's own reviewers, balanced runs the reviewers and deep runs the judge, the implementers and the improvement plans, where quality matters most.", "claude-code": { "fast": ["haiku", "medium"], "balanced": ["sonnet", "high"], - "deep": ["opus", "high"] + "deep": ["opus", "xhigh"] }, "codex": { - "fast": ["gpt-6-luna", "high"], + "fast": ["gpt-6-luna", "medium"], "balanced": ["gpt-6.1-sol", "high"], - "deep": ["gpt-6.1-sol", "high"] + "deep": ["gpt-6.1-sol", "xhigh"] + }, + "models": { + "_about": "Names that each engine's CLI takes: the Accounts page suggests them for the model of a tier, and an owner can type any other. Claude Code: the aliases, which mean the newest model of their family on the Anthropic API. `fable` is left out on purpose, since `claude -p` can bill a Fable request to usage credits without asking. Codex: the models that its own catalog lists for choosing (`codex debug models --bundled`) and has not replaced with a newer one, in the order of its picker. An API key's models come from models.dev instead, and a local model server's are the owner's.", + "claude-code": ["haiku", "sonnet", "opus"], + "codex": ["gpt-6.1-sol", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] }, "efforts": { "_about": "Lowest first. Claude Code's --effort; Codex's model_reasoning_effort; OpenCode's --variant, whose levels depend on the model.", diff --git a/src/lastcall/engines/base.py b/src/lastcall/engines/base.py index 1b0820a..87030cd 100644 --- a/src/lastcall/engines/base.py +++ b/src/lastcall/engines/base.py @@ -102,6 +102,7 @@ class Provider: title: str # as people know it: "OpenRouter" keys: str # where the owner creates an API key; "" for a local model server, which needs none models: str # where the owner finds the names of its models + catalog: str = "" # its id in models.dev, whose catalog lists its models; "" when there is none to ask @runtime_checkable diff --git a/src/lastcall/engines/opencode.py b/src/lastcall/engines/opencode.py index 037c283..386d946 100644 --- a/src/lastcall/engines/opencode.py +++ b/src/lastcall/engines/opencode.py @@ -37,19 +37,27 @@ KEY_FILE = f"{CONTEXT_DIR}/key" LOCAL = "ollama" # local models: an OpenAI-compatible server, by default the compose service `ollama` LOCAL_URL = "http://ollama:11434/v1" -PROVIDERS = ( - Provider("openrouter", "OpenRouter", "https://openrouter.ai/settings/keys", "https://openrouter.ai/models"), +PROVIDERS = ( # the models.dev id of each API is its name; a local model server has no catalog to ask + Provider( + "openrouter", + "OpenRouter", + "https://openrouter.ai/settings/keys", + "https://openrouter.ai/models", + "openrouter", + ), Provider( "anthropic", "Anthropic API", "https://platform.claude.com/settings/keys", "https://platform.claude.com/docs/en/models/overview", + "anthropic", ), Provider( "openai", "OpenAI API", "https://platform.openai.com/api-keys", "https://developers.openai.com/api/docs/models", + "openai", ), Provider(LOCAL, "Ollama", "", "https://ollama.com/library"), ) diff --git a/src/lastcall/locales/en.json b/src/lastcall/locales/en.json index 3ef18d0..ebe190b 100644 --- a/src/lastcall/locales/en.json +++ b/src/lastcall/locales/en.json @@ -231,6 +231,17 @@ "web.projects.never": "never", "web.projects.no_findings": "none yet", "web.projects.none": "No projects yet.", + "web.projects.search": "Search by name", + "web.projects.search_go": "Search", + "web.projects.show": "Show projects", + "web.projects.filter.all": "All", + "web.projects.filter.active": "Active", + "web.projects.filter.inactive": "Inactive", + "web.projects.filter.waiting": "Waiting for you", + "web.projects.showing.one": "Showing {shown} of {count} project.", + "web.projects.showing.other": "Showing {shown} of {count} projects.", + "web.projects.no_match": "No project matches this search and filter.", + "web.projects.show_all": "Show all projects", "web.finding.pending.one": "{count} pending", "web.finding.pending.other": "{count} pending", @@ -293,8 +304,9 @@ "web.project.weight.high": "High (×2)", "web.project.weight.top": "Highest (×3)", "web.project.weight.custom": "×{weight}", - "web.project.language": "Writes in", - "web.project.language.account": "The account's", + "web.project.language": "Language of its pull requests and issues", + "web.project.language.account": "Same as the account", + "web.project.language_hint": "“Same as the account” uses the language of whichever account works on it, set on the Accounts page.", "web.project.merge_on_approve": "Merge after my approval", "web.project.watch": "Watch after the merge", "web.project.tests": "Tests too", @@ -426,7 +438,7 @@ "web.accounts.id": "Name", "web.accounts.id_hint": "Lowercase letters, digits and dashes, such as claude-max.", "web.accounts.engine": "Subscription", - "web.accounts.language_label": "Writes in", + "web.accounts.language_label": "Language of its pull requests and issues", "web.accounts.reserve_label": "Reserve for you, % of every week", "web.accounts.enabled": "lastcall may spend it", "web.accounts.submit": "Add", @@ -459,6 +471,7 @@ "web.accounts.model_hint": "The model of every step, as the provider's list names it. lastcall counts what each step costs, so the model needs a price in that list.", "web.accounts.review_label": "Reviewers' model (optional)", "web.accounts.review_hint": "A cheaper model for the reviewers. Empty: the model above.", + "web.accounts.model_suggest": "The field suggests what models.dev lists for all the providers, and OpenRouter writes the maker first, as in qwen/qwen3-coder. Pick one from the provider you chose, or type any other.", "web.accounts.budget_label": "Monthly budget, USD", "web.accounts.budget_hint": "What its steps may cost each calendar month (UTC), whatever its policy. A step that is already running may go a little over it: keep the provider's own limit too.", "web.accounts.policy_label": "When it spends", @@ -560,24 +573,43 @@ "web.signals.failed": "The tracker did not answer as expected: {error}", "web.logs.title": "Container logs", "web.logs.intro": "If your apps run in Docker on this same machine, lastcall can read their containers' logs, with no change to your apps: the errors, grouped by kind, and the restarts.", - "web.logs.step1": "Choose the project of each container that you want read. lastcall suggests one from the container's image (ghcr.io/owner/name), its Compose project or its name, and reads nothing until you save.", + "web.logs.step1": "lastcall suggests a project for each container from its image (ghcr.io/owner/name), its Compose project or its name. It reads none until you choose it: tick the suggestions to read them, or type a project next to any other container.", "web.logs.step2": "lastcall then reads the last 24 hours of those containers, every 30 minutes and before each run: only their logs and state, never anything else of them.", "web.logs.project": "Project", - "web.logs.not_read": "None: not read", - "web.logs.suggested": "Suggested: {project}. Save to read it.", - "web.logs.from_label": "Read for {project}, from its label.", "web.logs.save": "Save", - "web.logs.none": "No containers on this machine.", + "web.logs.none": "No containers on this machine besides lastcall's own.", "web.logs.unavailable": "The containers can be listed only when lastcall runs with docker compose.", "web.logs.failed": "The containers could not be listed: {error}", + "web.logs.read_title": "Read now", + "web.logs.read_none": "Nothing is read yet.", + "web.logs.chosen_here": "chosen here", + "web.logs.label_decides": "its label decides", + "web.logs.label_unknown": "Its label names {project}, which is not a project: add it on the Projects page and it is read.", + "web.logs.stop": "Stop reading", + "web.logs.stop_label": "Stop reading {name}", + "web.logs.suggested_title": "Suggested", + "web.logs.for_project": "for {project}", + "web.logs.read_suggested.one": "Read the suggested one", + "web.logs.read_suggested.other": "Read the {count} suggested", + "web.logs.read_ticked": "Read the ticked", + "web.logs.skip_ticked": "Skip the ticked", + "web.logs.others.one": "{count} other container", + "web.logs.others.other": "{count} other containers", + "web.logs.matching.one": "{shown} of {count} matches “{query}”.", + "web.logs.matching.other": "{shown} of {count} match “{query}”.", + "web.logs.others_hint": "Type a project next to each one that you want read, then save.", + "web.logs.pick_placeholder": "Type to find a project", + "web.logs.project_for": "Project for {name}", + "web.logs.not_a_project": "“{project}” is not one of your projects. Pick one from the list that opens as you type: the name is the one the Projects page writes.", + "web.logs.none_ticked": "Tick at least one container.", "web.logs.label_title": "Or with a label in the compose file", - "web.logs.label_how": "Both ways work together. A container with the label lastcall.project is read for that project with nothing to choose: this page shows it with its project. Add it to the app's compose file, with the project's name as the Projects page writes it, and run docker compose up -d for that app:", + "web.logs.label_how": "Both ways work together. A container with the label lastcall.project is read for that project with nothing to choose: this page shows it under “Read now”, and its label decides. Add it to the app's compose file, with the project's name as the Projects page writes it, and run docker compose up -d for that app:", "web.loki.title": "Logs in Loki", "web.loki.intro": "If your apps run on other machines and Grafana Loki already collects their logs, lastcall can read them there, with nothing to install on those machines.", "web.loki.step1": "Give Loki's address, one that lastcall can reach, such as http://192.168.1.20:3100, or https://logs-prod-012.grafana.net in Grafana Cloud. lastcall reads it once to check it, and saves it only if that works.", "web.loki.step2": "If Loki asks for a login, give its user and a token that can only read. In Grafana Cloud, the user is the number in your Loki's details, and the token needs the logs:read scope. A Loki with no login needs neither.", - "web.loki.step3": "Choose the project of each source, and save. lastcall suggests one from the source's name or its Compose project, and reads nothing until you save.", + "web.loki.step3": "Choose the sources to read. lastcall suggests a project from a source's name or its Compose project, and reads nothing until you choose.", "web.loki.url": "Loki's address", "web.loki.user": "User", "web.loki.user_hint": "Only for a Loki with a login.", @@ -599,6 +631,9 @@ "web.loki.choose_label": "Choose a label", "web.loki.use_label": "Use this label", "web.loki.none": "Loki saw no streams with this label in the last 7 days.", + "web.loki.others.one": "{count} other source", + "web.loki.others.other": "{count} other sources", + "web.loki.none_ticked": "Tick at least one source.", "web.loki.compose": "Compose project: {project}", "web.tiers.title": "Models and effort", @@ -617,6 +652,8 @@ "web.tiers.keyed_hint": "OpenCode passes the effort as the model's variant: choose one that your model offers, or keep Default.", "web.tiers.save": "Save models and effort", "web.tiers.need_models": "Give a model for each level: an account on an API key or a local model has no default.", + "web.tiers.suggest.cli": "The Model field suggests the names that {tool} takes. Pick one, or type any other.", + "web.tiers.suggest.catalog": "The Model field suggests what models.dev lists for {provider}: models that use tools and have a price. Pick one, or type any other.", "web.time.now": "just now", "web.time.ago": "{span} ago", @@ -637,6 +674,7 @@ "web.add.placeholder": "owner/name", "web.error.signin": "Back to sign in", "web.project.run_now": "Run now", + "web.project.run_summary": "Starts a run right away, on the account you choose.", "web.project.settings_summary": "Settings", "web.projects.intro": "lastcall works on these repositories when an account has quota it would lose. Choose what it may do in each one.", "web.projects.empty": "No projects yet. Pick one of your repositories below: lastcall says why each one is worth it.", @@ -681,7 +719,7 @@ "web.help.common.terminal.title": "Where to type it", "web.help.accounts.check.title": "Reading the quota", "web.help.settings.loki_sources.title": "Sources", - "web.help.projects.turns.title": "Tests and upgrades in turn", + "web.help.projects.turns.title": "Tests and dependencies in turn", "web.help.projects.tickets.title": "Labelled issues", "web.help.projects.score.title": "Points", "web.help.projects.size.title": "Tokens of code", @@ -717,12 +755,12 @@ "web.help.dashboard.status": "Status: stopped early means that before a step the account had reached your reserve or its monthly budget, had paid usage on, or had no quota reading, so the run ended there. Interrupted means lastcall itself stopped while the run was going, for example at a restart. Failed means an error stopped it.", "web.help.projects.active": "Active projects take part in bursts. Even then, a burst skips a project while it has 2 lastcall pull requests waiting for you, or when the same account audited it in the last 7 days, unless you ticked ideas or labelled issues for it. The run buttons work even when the project is inactive. Click the state at the top of a card to turn bursts on or off; the “Active” box in Settings does the same.", "web.help.projects.weight": "When an account has quota to spend, lastcall gives each active project points (never audited by that account, days and new commits since its last audit, pending findings, errors in production, what you asked for) and starts with the highest. Priority multiplies those points: High (×2) doubles them, Low (×0.5) halves them. It does not make a run longer.", - "web.help.projects.language": "The language of this project's pull requests, issues and reports. With “The account's”, lastcall writes in the language of whichever account does the run, set on the Accounts page. It does not change these pages or your notifications.", + "web.help.projects.language": "The language of this project's pull requests, issues and reports. With “Same as the account”, lastcall writes in the language of whichever account does the run, set on the Accounts page. It does not change these pages or your notifications.", "web.help.projects.max_prs": "The most pull requests one run opens on this project, whatever it does: an audit, tests, dependency upgrades, the ideas of a plan or labelled issues. What does not fit waits for a later run.", - "web.help.projects.merge_on_approve": "On GitHub, approving a pull request does not merge it. With this on, lastcall merges its own pull requests (never a revert) once someone who can write to the repository approves the latest commit, nobody with write access asks for changes, and no check fails or is still running. If one no longer merges cleanly, lastcall closes it and a later run makes the change again on the current code.", - "web.help.projects.watch": "For a day after one of lastcall's pull requests is merged, lastcall watches the checks of the merged commit and the health address below. If the checks fail where they passed before, or the address fails 3 times in a row, it opens a pull request that reverts the change, pauses the project (unticks “Active”) and tells you. It never merges the revert: you decide.", + "web.help.projects.merge_on_approve": "On GitHub, approving a pull request does not merge it. With this on, lastcall merges its own pull requests (never a revert) at its next look at GitHub, which is every 10 minutes: once someone who can write to the repository has approved the latest commit, nobody with write access asks for changes, and no check fails or is still running. If GitHub does not let it merge yet, for example because a rule of a protected branch is not met, lastcall waits. If one no longer merges cleanly, lastcall closes it and a later run makes the change again on the current code. Without this, you merge them yourself.", + "web.help.projects.watch": "For a day after one of lastcall's pull requests is merged, lastcall watches two things only: the checks of the merged commit and, if you give one, the health address below. If the checks fail where they passed before, or the address fails 3 times in a row, it opens a pull request that reverts the change, pauses the project (unticks “Active”) and tells you. It never merges the revert: you decide. Container logs, Loki and your error tracker take no part in the watch: they feed the audits, which use them to find the causes of errors.", "web.help.projects.health_url": "Used only while “Watch after the merge” is on and a merge is being watched. lastcall asks it every 10 minutes; any answer with a status from 200 to 399 within 10 seconds counts as working.", - "web.help.projects.turns": "Bursts on this project take turns: an audit, then tests, then dependency upgrades (each only if ticked), then an audit again. Tests too adds tests for behavior no test checks. Dependencies too upgrades what needs more than a new version number: known vulnerabilities, deprecated packages, major versions behind. Ideas you ticked in a plan and issues labelled lastcall always go first.", + "web.help.projects.turns": "The ideas you ticked in a plan and the issues labelled lastcall always go first. After them, a burst on this project audits it. Tick “Tests too”, “Dependencies too” or both, and the bursts take turns: an audit, then a run that writes tests, then one that upgrades dependencies (each only if ticked), then an audit again. A tests run opens pull requests that only add tests, for behavior no test checks. A dependencies run opens pull requests that upgrade what needs more than a new version number (a known vulnerability, a deprecated package, a major version behind, a runtime past its end of life) and fix the code that the new version breaks; plain minor and patch upgrades are left to bots such as Dependabot. The buttons “Write tests now” and “Upgrade dependencies now” run them at once, ticked or not.", "web.help.projects.signals": "Its short name in the tracker's URLs. lastcall reads this project's unresolved errors with a read-only token: a project with errors in the last day ranks higher, and its audits see the error's type, its message with personal data masked, and the lines of your own code. Request data, user data and local variables never reach a model.", "web.help.projects.account": "Any free account takes the account that is not running anything and has the most quota left before its reset. Choose an account to use that one.", "web.help.projects.account_all": "With the option for all free accounts, every free account reviews the project and the one with the most quota left judges the findings and writes the fixes. Only an audit can use them all: for Improve now, tests and upgrades it means any free account.", @@ -750,9 +788,10 @@ "web.help.settings.language": "Saved in this browser for a year. Until you choose, lastcall follows your browser's language. Your notifications have their own language, under Notifications, and what lastcall writes on GitHub follows each account and project.", "web.help.settings.notify_language": "The language of the messages lastcall sends you, whatever language these pages show. Until you save one, lastcall takes the language of these pages when you add your first destination.", "web.help.settings.signals_project": "In Sentry and GlitchTip, each app sends its errors to a project with a short name in its URLs. Give that name to a lastcall project on the Projects page: its audits then read its unresolved errors of the last 14 days, the most frequent first.", - "web.help.settings.logs_project": "The list holds the repositories on your Projects page. lastcall reads a container's logs only once you give it a project. Saving “None: not read” on a container lastcall proposed records your no, so the proposal does not come back.", + "web.help.settings.logs_project": "Type to narrow the list, which holds the repositories on your Projects page: write one as that page does, in any case. Anything else is refused. lastcall reads the logs of a container, or of a source, only once you give it a project.", + "web.help.settings.logs_suggested": "lastcall pairs a container, or a source in Loki, with one of your projects from its image, its Compose project or its name, when only one project fits. It reads nothing until you read it here. Skipping a suggestion records your no, so that it does not come back; you can still give it a project below, among the others.", "web.help.settings.loki_label": "Loki tags each stream of logs with labels. lastcall lists the values of one label as sources, one per app or container, seen in the last 7 days. It uses the first that your Loki has of container, container_name, service_name, app and job. Choose another only if your apps go by a different label: your project choices then start over.", - "web.help.settings.loki_sources": "The names next to each source are the machines where it ran, from the host label of its logs. The Compose project, when Loki has it, is one of the clues lastcall uses to propose a project; it reads nothing until you save one.", + "web.help.settings.loki_sources": "The names next to each source are the machines where it ran, from the host label of its logs. The Compose project, when Loki has it, is one of the clues lastcall uses to propose a project; it reads nothing until you choose one.", "web.help.settings.loki_tenant": "Leave it empty unless your Loki keeps the logs of several teams apart (multi-tenant) and its admin gave you a tenant name. lastcall sends it in the X-Scope-OrgID header of every request.", "web.help.settings.loki_token": "With a user, lastcall signs in with that user and this password (basic login). Without a user, it sends this as a bearer token, the way a token-protected Loki or proxy expects.", "web.help.settings.destination": "Each line is the destination as Apprise, the library that sends lastcall's notifications, writes it, with its secrets hidden. The full address stays in lastcall's secret store.", @@ -792,14 +831,14 @@ "web.project.legend.when": "When lastcall works on it", "web.project.legend.writes": "What it writes and may do", "web.project.active_hint": "Bursts may pick it. The run buttons work either way.", - "web.project.merge_hint": "lastcall merges its own pull requests once they are approved and their checks pass.", - "web.project.watch_hint": "For a day after a merge, if the checks or the health address break, lastcall proposes a revert and pauses the project.", + "web.project.merge_hint": "When you approve one of its pull requests on GitHub, lastcall merges it once its checks pass. Without this, you merge it yourself.", + "web.project.watch_hint": "For a day after a merge, lastcall watches only the merged commit's checks and the health address below, not your logs or error tracker. If either breaks, it proposes a revert and pauses the project.", "web.project.checks.lead": "“{merge}” and “{watch}” need the GitHub App to read checks and commit statuses, and this App cannot yet.", "web.project.checks.open": "Open the App's permissions on GitHub.", "web.project.checks.set": "Set “Checks” and “Commit statuses” to “Read-only” and save.", "web.project.checks.accept": "Accept the new permissions where GitHub asks for it, on the App's installation: Settings › Applications › Installed GitHub Apps › Configure, or the organization's settings.", - "web.project.tests_turn": "In turn with the audits, it adds tests for behavior that no test checks.", - "web.project.deps_turn": "In turn with the audits, it upgrades what needs more than a new version number.", + "web.project.tests_turn": "Some bursts write tests instead of auditing: pull requests that add tests for behavior no test checks.", + "web.project.deps_turn": "Some bursts upgrade dependencies instead of auditing: pull requests for what needs more than a new version number.", "web.project.run_hints": "What each button does", "web.settings.events_hint": "They apply once you add a destination above.", "web.nav.breadcrumb": "Breadcrumb", diff --git a/src/lastcall/locales/es.json b/src/lastcall/locales/es.json index 833f31c..1fc91a1 100644 --- a/src/lastcall/locales/es.json +++ b/src/lastcall/locales/es.json @@ -231,6 +231,17 @@ "web.projects.never": "nunca", "web.projects.no_findings": "ninguno aún", "web.projects.none": "Aún no hay proyectos.", + "web.projects.search": "Buscar por nombre", + "web.projects.search_go": "Buscar", + "web.projects.show": "Mostrar proyectos", + "web.projects.filter.all": "Todos", + "web.projects.filter.active": "Activos", + "web.projects.filter.inactive": "Inactivos", + "web.projects.filter.waiting": "Pendientes de ti", + "web.projects.showing.one": "Mostrando {shown} de {count} proyecto.", + "web.projects.showing.other": "Mostrando {shown} de {count} proyectos.", + "web.projects.no_match": "Ningún proyecto coincide con esta búsqueda y este filtro.", + "web.projects.show_all": "Ver todos los proyectos", "web.finding.pending.one": "{count} pendiente", "web.finding.pending.other": "{count} pendientes", @@ -293,8 +304,9 @@ "web.project.weight.high": "Alta (×2)", "web.project.weight.top": "Máxima (×3)", "web.project.weight.custom": "×{weight}", - "web.project.language": "Escribe en", - "web.project.language.account": "El de la cuenta", + "web.project.language": "Idioma de sus pull requests e issues", + "web.project.language.account": "Igual que la cuenta", + "web.project.language_hint": "«Igual que la cuenta» usa el idioma de la cuenta que trabaje en él, el que se elige en Cuentas.", "web.project.merge_on_approve": "Fusionar tras mi aprobación", "web.project.watch": "Vigilar tras la fusión", "web.project.tests": "También tests", @@ -426,7 +438,7 @@ "web.accounts.id": "Nombre", "web.accounts.id_hint": "Minúsculas, dígitos y guiones, como claude-max.", "web.accounts.engine": "Suscripción", - "web.accounts.language_label": "Escribe en", + "web.accounts.language_label": "Idioma de sus pull requests e issues", "web.accounts.reserve_label": "Reserva para ti, % de cada semana", "web.accounts.enabled": "lastcall puede gastarla", "web.accounts.submit": "Añadir", @@ -459,6 +471,7 @@ "web.accounts.model_hint": "El modelo de todos los pasos, tal como lo nombra la lista del proveedor. lastcall cuenta lo que cuesta cada paso, así que el modelo necesita un precio en esa lista.", "web.accounts.review_label": "Modelo de los revisores (opcional)", "web.accounts.review_hint": "Un modelo más barato para los revisores. Vacío: el de arriba.", + "web.accounts.model_suggest": "El campo sugiere lo que models.dev lista para todos los proveedores, y OpenRouter escribe primero el fabricante, como en qwen/qwen3-coder. Elige uno del proveedor que seleccionaste, o escribe cualquier otro.", "web.accounts.budget_label": "Presupuesto mensual, USD", "web.accounts.budget_hint": "Lo que pueden costar sus pasos cada mes natural (UTC), sea cual sea su política. Un paso que ya está en marcha puede pasarse un poco: mantén también el límite del proveedor.", "web.accounts.policy_label": "Cuándo gasta", @@ -560,24 +573,43 @@ "web.signals.failed": "El tracker no respondió como se esperaba: {error}", "web.logs.title": "Logs de los contenedores", "web.logs.intro": "Si tus apps corren en Docker en esta misma máquina, lastcall puede leer los logs de sus contenedores, sin cambiar nada de tus apps: los errores, agrupados por tipo, y los reinicios.", - "web.logs.step1": "Elige el proyecto de cada contenedor que quieras que lea. lastcall sugiere uno por la imagen del contenedor (ghcr.io/owner/name), su proyecto de Compose o su nombre, y no lee nada hasta que guardes.", + "web.logs.step1": "lastcall sugiere un proyecto para cada contenedor por su imagen (ghcr.io/owner/name), su proyecto de Compose o su nombre. No lee ninguno hasta que lo elijas: marca las sugerencias para leerlas, o escribe un proyecto junto a cualquier otro contenedor.", "web.logs.step2": "Desde entonces lastcall lee las últimas 24 horas de esos contenedores, cada 30 minutos y antes de cada ejecución: solo sus logs y su estado, nunca nada más de ellos.", "web.logs.project": "Proyecto", - "web.logs.not_read": "Ninguno: no se lee", - "web.logs.suggested": "Sugerido: {project}. Guarda para leerlo.", - "web.logs.from_label": "Se lee para {project}, por su etiqueta.", "web.logs.save": "Guardar", - "web.logs.none": "No hay contenedores en esta máquina.", + "web.logs.none": "No hay más contenedores en esta máquina que los del propio lastcall.", "web.logs.unavailable": "Los contenedores solo se pueden listar cuando lastcall corre con docker compose.", "web.logs.failed": "No se pudieron listar los contenedores: {error}", + "web.logs.read_title": "Se lee ahora", + "web.logs.read_none": "Todavía no se lee nada.", + "web.logs.chosen_here": "elegido aquí", + "web.logs.label_decides": "lo decide su etiqueta", + "web.logs.label_unknown": "Su etiqueta nombra a {project}, que no es un proyecto: añádelo en la página de proyectos y se lee.", + "web.logs.stop": "Dejar de leer", + "web.logs.stop_label": "Dejar de leer {name}", + "web.logs.suggested_title": "Sugeridos", + "web.logs.for_project": "para {project}", + "web.logs.read_suggested.one": "Leer el sugerido", + "web.logs.read_suggested.other": "Leer los {count} sugeridos", + "web.logs.read_ticked": "Leer los marcados", + "web.logs.skip_ticked": "Descartar los marcados", + "web.logs.others.one": "{count} otro contenedor", + "web.logs.others.other": "{count} otros contenedores", + "web.logs.matching.one": "{shown} de {count} coincide con «{query}».", + "web.logs.matching.other": "{shown} de {count} coinciden con «{query}».", + "web.logs.others_hint": "Escribe un proyecto junto a cada uno que quieras leer y guarda.", + "web.logs.pick_placeholder": "Escribe para buscar un proyecto", + "web.logs.project_for": "Proyecto de {name}", + "web.logs.not_a_project": "«{project}» no es uno de tus proyectos. Elige uno de la lista que se abre al escribir: el nombre es el que escribe la página de proyectos.", + "web.logs.none_ticked": "Marca al menos un contenedor.", "web.logs.label_title": "O con una etiqueta en el compose", - "web.logs.label_how": "Las dos formas conviven. Un contenedor con la etiqueta lastcall.project se lee para ese proyecto sin elegir nada: esta página lo muestra con su proyecto. Añádela al compose de la app, con el nombre del proyecto tal como lo escribe la página de proyectos, y ejecuta docker compose up -d en esa app:", + "web.logs.label_how": "Las dos formas conviven. Un contenedor con la etiqueta lastcall.project se lee para ese proyecto sin elegir nada: esta página lo muestra en «Se lee ahora» y la etiqueta decide. Añádela al compose de la app, con el nombre del proyecto tal como lo escribe la página de proyectos, y ejecuta docker compose up -d en esa app:", "web.loki.title": "Logs en Loki", "web.loki.intro": "Si tus apps corren en otras máquinas y Grafana Loki ya recoge sus logs, lastcall puede leerlos allí, sin instalar nada en esas máquinas.", "web.loki.step1": "Da la dirección de Loki, una a la que lastcall llegue, como http://192.168.1.20:3100, o https://logs-prod-012.grafana.net en Grafana Cloud. lastcall la lee una vez para comprobarla y solo la guarda si funciona.", "web.loki.step2": "Si Loki pide usuario, da su usuario y un token que solo pueda leer. En Grafana Cloud, el usuario es el número que aparece en los detalles de tu Loki, y el token necesita el permiso logs:read. Un Loki sin usuario no necesita ninguno.", - "web.loki.step3": "Elige el proyecto de cada fuente y guarda. lastcall sugiere uno por el nombre de la fuente o su proyecto de Compose, y no lee nada hasta que guardes.", + "web.loki.step3": "Elige las fuentes que quieres leer. lastcall sugiere un proyecto por el nombre de la fuente o su proyecto de Compose, y no lee nada hasta que elijas.", "web.loki.url": "Dirección de Loki", "web.loki.user": "Usuario", "web.loki.user_hint": "Solo para un Loki con usuario.", @@ -599,6 +631,9 @@ "web.loki.choose_label": "Elige una etiqueta", "web.loki.use_label": "Usar esta etiqueta", "web.loki.none": "Loki no vio streams con esta etiqueta en los últimos 7 días.", + "web.loki.others.one": "{count} otra fuente", + "web.loki.others.other": "{count} otras fuentes", + "web.loki.none_ticked": "Marca al menos una fuente.", "web.loki.compose": "Proyecto de Compose: {project}", "web.tiers.title": "Modelos y esfuerzo", @@ -617,6 +652,8 @@ "web.tiers.keyed_hint": "OpenCode pasa el esfuerzo como variante del modelo: elige una que tu modelo ofrezca, o deja Por defecto.", "web.tiers.save": "Guardar modelos y esfuerzo", "web.tiers.need_models": "Da un modelo para cada nivel: una cuenta con API key o modelo local no tiene valor por defecto.", + "web.tiers.suggest.cli": "El campo Modelo sugiere los nombres que acepta {tool}. Elige uno o escribe cualquier otro.", + "web.tiers.suggest.catalog": "El campo Modelo sugiere lo que models.dev lista para {provider}: modelos que usan herramientas y tienen precio. Elige uno o escribe cualquier otro.", "web.time.now": "ahora mismo", "web.time.ago": "hace {span}", @@ -637,6 +674,7 @@ "web.add.placeholder": "propietario/nombre", "web.error.signin": "Volver a iniciar sesión", "web.project.run_now": "Ejecutar ahora", + "web.project.run_summary": "Empieza una ejecución ya, en la cuenta que elijas.", "web.project.settings_summary": "Ajustes", "web.projects.intro": "lastcall trabaja en estos repositorios cuando una cuenta tiene cuota que perdería. Elige qué puede hacer en cada uno.", "web.projects.empty": "Aún no hay proyectos. Elige uno de tus repositorios aquí abajo: lastcall dice por qué merece la pena cada uno.", @@ -717,12 +755,12 @@ "web.help.dashboard.status": "Estado: parada antes de tiempo significa que, antes de un paso, la cuenta había llegado a tu reserva o a su presupuesto mensual, tenía activo el uso de pago o no tenía lectura de cuota, así que la ejecución terminó ahí. Interrumpida significa que lastcall se detuvo mientras la ejecución estaba en marcha, por ejemplo al reiniciarse. Fallida significa que un error la detuvo.", "web.help.projects.active": "Los proyectos activos entran en las ráfagas. Aun así, una ráfaga se salta un proyecto mientras tenga 2 pull requests de lastcall esperándote, o si la misma cuenta lo auditó en los últimos 7 días, salvo que hayas marcado ideas o etiquetado issues para él. Los botones para lanzar una ejecución funcionan aunque el proyecto esté inactivo. Haz clic en el estado, arriba en la tarjeta, para activar o desactivar las ráfagas; la casilla «Activo» de Ajustes hace lo mismo.", "web.help.projects.weight": "Cuando una cuenta tiene cuota que gastar, lastcall da puntos a cada proyecto activo (nunca auditado por esa cuenta, días y commits nuevos desde su última auditoría, hallazgos pendientes, errores en producción, lo que hayas pedido) y empieza por el que más tiene. La prioridad multiplica esos puntos: Alta los duplica y Baja los reduce a la mitad. No hace la ejecución más larga.", - "web.help.projects.language": "El idioma de los pull requests, issues e informes de este proyecto. Con «El de la cuenta», lastcall escribe en el idioma de la cuenta que haga la ejecución, el que se elige en Cuentas. No cambia estas páginas ni tus notificaciones.", + "web.help.projects.language": "El idioma de los pull requests, issues e informes de este proyecto. Con «Igual que la cuenta», lastcall escribe en el idioma de la cuenta que haga la ejecución, el que se elige en Cuentas. No cambia estas páginas ni tus notificaciones.", "web.help.projects.max_prs": "El máximo de pull requests que abre una ejecución en este proyecto, haga lo que haga: una auditoría, tests, actualizaciones de dependencias, las ideas de un plan o issues etiquetados. Lo que no cabe espera a una ejecución posterior.", - "web.help.projects.merge_on_approve": "En GitHub, aprobar un pull request no lo fusiona. Con esto activado, lastcall fusiona sus propios pull requests (nunca una reversión) cuando alguien con permiso de escritura aprueba el último commit, nadie con ese permiso pide cambios y ningún check falla ni sigue en curso. Si uno ya no se fusiona limpio, lastcall lo cierra y una ejecución posterior rehace el cambio sobre el código actual.", - "web.help.projects.watch": "Durante un día después de fusionar uno de los pull requests de lastcall, lastcall vigila los checks del commit fusionado y la dirección de salud de abajo. Si los checks fallan donde antes pasaban, o la dirección falla 3 veces seguidas, abre un pull request que deshace el cambio, pausa el proyecto (desmarca «Activo») y te avisa. Nunca fusiona esa reversión: decides tú.", + "web.help.projects.merge_on_approve": "En GitHub, aprobar un pull request no lo fusiona. Con esto activado, lastcall fusiona sus propios pull requests (nunca una reversión) en su siguiente consulta a GitHub, que es cada 10 minutos: cuando alguien con permiso de escritura en el repositorio ha aprobado el último commit, nadie con ese permiso pide cambios y ningún check falla ni sigue en curso. Si GitHub todavía no deja fusionarlo, por ejemplo porque no se cumple una regla de una rama protegida, lastcall espera. Si uno ya no se fusiona limpio, lastcall lo cierra y una ejecución posterior rehace el cambio sobre el código actual. Sin esto, los fusionas tú.", + "web.help.projects.watch": "Durante un día después de fusionar uno de los pull requests de lastcall, lastcall vigila solo dos cosas: los checks del commit fusionado y, si das una, la dirección de salud de abajo. Si los checks fallan donde antes pasaban, o la dirección falla 3 veces seguidas, abre un pull request que deshace el cambio, pausa el proyecto (desmarca «Activo») y te avisa. Nunca fusiona esa reversión: decides tú. Los logs de contenedores, Loki y tu tracker de errores no intervienen en la vigilancia: alimentan las auditorías, que los usan para encontrar las causas de los errores.", "web.help.projects.health_url": "Solo se usa con «Vigilar tras la fusión» activado y mientras se vigila una fusión. lastcall la consulta cada 10 minutos; cualquier respuesta con estado de 200 a 399 en menos de 10 segundos cuenta como que funciona.", - "web.help.projects.turns": "Las ráfagas en este proyecto se turnan: una auditoría, luego tests y luego actualizaciones de dependencias (cada una solo si está marcada), y otra vez una auditoría. «También tests» añade tests para el comportamiento que ningún test comprueba. «También dependencias» actualiza lo que necesita algo más que un número de versión nuevo: vulnerabilidades conocidas, paquetes obsoletos, versiones mayores atrasadas. Las ideas que marcaste en un plan y los issues con la etiqueta lastcall siempre van primero.", + "web.help.projects.turns": "Las ideas que marcaste en un plan y los issues con la etiqueta lastcall siempre van primero. Después, una ráfaga en este proyecto lo audita. Marca «También tests», «También dependencias» o las dos y las ráfagas se turnan: una auditoría, luego una ejecución que escribe tests, luego otra que actualiza dependencias (cada una solo si está marcada) y otra vez una auditoría. Una ejecución de tests abre pull requests que solo añaden tests, para el comportamiento que ningún test comprueba. Una ejecución de dependencias abre pull requests que actualizan lo que necesita algo más que un número de versión nuevo (una vulnerabilidad conocida, un paquete obsoleto, una versión mayor atrasada, un runtime que ya no tiene soporte) y arreglan el código que rompe la versión nueva; las actualizaciones menores y de parche sin más se dejan a bots como Dependabot. Los botones «Escribir tests ahora» y «Actualizar dependencias ahora» las lanzan al momento, estén marcadas o no.", "web.help.projects.signals": "Su nombre corto en las URLs de Sentry o GlitchTip. lastcall lee los errores sin resolver de este proyecto con un token de solo lectura: un proyecto con errores en el último día sube en la cola, y sus auditorías ven el tipo de error, el mensaje con los datos personales ocultos y las líneas de tu propio código. Los datos de la petición, del usuario y las variables locales nunca llegan a un modelo.", "web.help.projects.account": "«Cualquier cuenta libre» usa la cuenta que no está ejecutando nada y tiene más cuota sobrante antes de su reinicio. Elige una cuenta para usar esa.", "web.help.projects.account_all": "Con la opción de todas las cuentas libres, todas revisan el proyecto y la que tiene más cuota sobrante juzga los hallazgos y escribe los arreglos. Solo una auditoría puede usarlas todas: para Mejorar ahora, tests y dependencias equivale a cualquier cuenta libre.", @@ -750,9 +788,10 @@ "web.help.settings.language": "Se guarda en este navegador durante un año. Hasta que elijas, lastcall sigue el idioma de tu navegador. Tus notificaciones tienen su propio idioma, en Notificaciones, y lo que lastcall escribe en GitHub sigue el de cada cuenta y proyecto.", "web.help.settings.notify_language": "El idioma de los mensajes que te envía lastcall, sea cual sea el idioma de estas páginas. Hasta que guardes uno, lastcall toma el idioma de estas páginas al añadir tu primer destino.", "web.help.settings.signals_project": "En Sentry y GlitchTip, cada app envía sus errores a un proyecto con un nombre corto que aparece en sus URLs. Da ese nombre a un proyecto de lastcall en la página de proyectos: sus auditorías leerán entonces sus errores sin resolver de los últimos 14 días, los más frecuentes primero.", - "web.help.settings.logs_project": "La lista son los repositorios de tu página de proyectos. lastcall solo lee los logs de un contenedor cuando le das un proyecto. Si guardas «Ninguno: no se lee» en un contenedor que propuso lastcall, queda anotado tu no y esa propuesta no vuelve.", + "web.help.settings.logs_project": "Escribe para acotar la lista, que son los repositorios de tu página de proyectos: escribe uno como esa página, con mayúsculas o minúsculas. Cualquier otra cosa se rechaza. lastcall solo lee los logs de un contenedor, o de una fuente, cuando le das un proyecto.", + "web.help.settings.logs_suggested": "lastcall empareja un contenedor, o una fuente de Loki, con uno de tus proyectos por su imagen, su proyecto de Compose o su nombre, cuando solo uno encaja. No lee nada hasta que lo leas aquí. Si descartas una sugerencia queda anotado tu no y no vuelve; aun así puedes darle un proyecto más abajo, entre los demás.", "web.help.settings.loki_label": "Loki marca cada flujo de logs con etiquetas. lastcall lista como fuentes los valores de una etiqueta, uno por app o contenedor, vistos en los últimos 7 días. Usa la primera que tenga tu Loki de container, container_name, service_name, app y job. Elige otra solo si tus apps se llaman por otra etiqueta: tus elecciones de proyecto empiezan entonces de cero.", - "web.help.settings.loki_sources": "Los nombres junto a cada fuente son las máquinas donde se ejecutó, según la etiqueta host de sus logs. El proyecto de Compose, cuando Loki lo tiene, es una de las pistas que usa lastcall para proponer un proyecto; no lee nada hasta que guardes uno.", + "web.help.settings.loki_sources": "Los nombres junto a cada fuente son las máquinas donde se ejecutó, según la etiqueta host de sus logs. El proyecto de Compose, cuando Loki lo tiene, es una de las pistas que usa lastcall para proponer un proyecto; no lee nada hasta que elijas uno.", "web.help.settings.loki_tenant": "Déjalo vacío salvo que tu Loki guarde por separado los logs de varios equipos (multi-tenant) y su administrador te haya dado un nombre de tenant. lastcall lo envía en la cabecera X-Scope-OrgID de cada petición.", "web.help.settings.loki_token": "Con usuario, lastcall entra con ese usuario y esta contraseña (login básico). Sin usuario, lo envía como token bearer, como espera un Loki o un proxy protegido con token.", "web.help.settings.destination": "Cada línea es el destino tal como lo escribe Apprise, la librería que envía las notificaciones de lastcall, con sus secretos ocultos. La dirección completa se queda en el almacén de secretos de lastcall.", @@ -792,14 +831,14 @@ "web.project.legend.when": "Cuándo trabaja lastcall en él", "web.project.legend.writes": "Qué escribe y qué puede hacer", "web.project.active_hint": "Las ráfagas pueden elegirlo. Los botones de ejecutar funcionan igual.", - "web.project.merge_hint": "lastcall fusiona sus propios pull requests en cuanto los aprueban y pasan sus checks.", - "web.project.watch_hint": "Durante un día tras una fusión, si fallan los checks o la dirección de salud, lastcall propone una reversión y pausa el proyecto.", + "web.project.merge_hint": "Cuando apruebas uno de sus pull requests en GitHub, lastcall lo fusiona en cuanto pasan sus checks. Sin esto, lo fusionas tú.", + "web.project.watch_hint": "Durante un día tras una fusión, lastcall vigila solo los checks del commit fusionado y la dirección de salud de abajo, no tus logs ni tu tracker de errores. Si alguno falla, propone una reversión y pausa el proyecto.", "web.project.checks.lead": "«{merge}» y «{watch}» necesitan que la GitHub App lea los checks y los estados de commit, y esta App todavía no puede.", "web.project.checks.open": "Abre los permisos de la App en GitHub.", "web.project.checks.set": "Pon «Checks» y «Commit statuses» en «Read-only» y guarda.", "web.project.checks.accept": "Acepta los nuevos permisos donde GitHub te lo pida, en la instalación de la App: Settings › Applications › Installed GitHub Apps › Configure, o en los ajustes de la organización.", - "web.project.tests_turn": "Por turnos con las auditorías, añade tests para el comportamiento que ningún test comprueba.", - "web.project.deps_turn": "Por turnos con las auditorías, actualiza lo que necesita algo más que un número de versión nuevo.", + "web.project.tests_turn": "Algunas ráfagas escriben tests en lugar de auditar: pull requests que añaden tests para el comportamiento que ningún test comprueba.", + "web.project.deps_turn": "Algunas ráfagas actualizan dependencias en lugar de auditar: pull requests para lo que necesita algo más que un número de versión nuevo.", "web.project.run_hints": "Qué hace cada botón", "web.settings.events_hint": "Se aplican cuando añadas un destino arriba.", "web.nav.breadcrumb": "Ruta de navegación", diff --git a/src/lastcall/logs.py b/src/lastcall/logs.py index 7a66a9f..585a710 100644 --- a/src/lastcall/logs.py +++ b/src/lastcall/logs.py @@ -17,7 +17,7 @@ import json import re -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Collection, Iterable, Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING, Any @@ -29,9 +29,13 @@ from lastcall.store import Store LABEL = "lastcall.project" +OWN = "lastcall" # lastcall's own Compose project (compose.yaml): its containers are named lastcall-- CHOSEN = "container_projects" # the owner's choices: container name -> owner/name, as JSON HOURS = 24 MAX_CONTAINERS = 10 # per project +MAX_PROJECT = ( + 140 # characters of a repository's name (owner/name) at most: what a refusal gives back of what was typed +) TITLE = f"Errors in the container logs (last {HOURS} hours)" MAX_KINDS = 8 MAX_FRAMES = 6 @@ -71,18 +75,134 @@ def shown(choice: str, repos: Iterable[str]) -> tuple[str, str]: """What the Settings page shows of the owner's choice for a container or a source: the project, as the projects page writes it, while it is one; or, apart, the name that the choice keeps after the owner removed the project. Settings keeps that choice, so that adding the project again brings it back, and the page shows none meanwhile: - a form that sent the removed name back as the one it showed would erase it.""" + no form asks for it to go, and an empty field changes nothing (`apply_form`).""" for repo in repos: if repo.lower() == choice.lower(): return repo, "" return "", choice +def project_named(typed: str, repos: Iterable[str]) -> str | None: + """The project that `typed` names, as the Projects page writes it, whatever the case; None when it names none.""" + wanted = typed.strip().lower() + return next((repo for repo in repos if repo.lower() == wanted), None) + + def project_of(container: Mapping[str, Any], choices: Mapping[str, str]) -> str: """The project of a container: its label, or else the owner's choice; "" for none.""" return str(container.get("label") or "") or choices.get(str(container.get("name") or ""), "") +def is_own(item: Mapping[str, Any]) -> bool: + """Whether a container, or a source in Loki, is one of lastcall's own: in its Compose project, or named + `lastcall-...`. The settings page never lists them: lastcall does not read its own logs.""" + return str(item.get("compose_project") or "").lower() == OWN or str(item.get("name") or "").lower().startswith( + OWN + "-" + ) + + +@dataclass(frozen=True) +class Parts: + """The containers of a host, or the sources of a Loki, in the three parts that the settings page shows.""" + + read: list[dict[str, Any]] # lastcall reads them: by their label, or by the owner's choice of a project + suggested: list[dict[str, Any]] # lastcall found a project for them, which the owner has not chosen yet + others: list[dict[str, Any]] # the rest: no project fits, the owner said no, or the chosen project is gone + + @property + def count(self) -> int: + return len(self.read) + len(self.suggested) + len(self.others) + + def others_named(self, query: str) -> list[dict[str, Any]]: + """The others whose name contains `query`, whatever the case; all of them for an empty one.""" + wanted = query.casefold() + return [o for o in self.others if wanted in str(o["name"]).casefold()] + + +def parts(listed: Iterable[Mapping[str, Any]], repos: Sequence[str], choices: Mapping[str, str]) -> Parts: + """Sort what is listed into read, suggested and the rest, never lastcall's own. A read one has `project`, as the + Projects page writes it, and `by` ("label" or "choice"); a label names a project that may not exist (`unknown`). + The others carry `suggested` and `gone` (see `shown`). A label wins over a choice, and a suggestion is no choice: + nothing is read until the owner chooses it.""" + read: list[dict[str, Any]] = [] + suggested: list[dict[str, Any]] = [] + others: list[dict[str, Any]] = [] + for item in listed: + if is_own(item): + continue + name, label = str(item.get("name") or ""), str(item.get("label") or "") + choice, gone = shown(choices.get(name, ""), repos) + if label: + project, unnamed = shown(label, repos) + read.append({**item, "project": project or unnamed, "by": "label", "unknown": not project}) + elif choice: + read.append({**item, "project": choice, "by": "choice"}) + else: + hint = "" if name in choices else suggest(item, repos) + (suggested if hint else others).append({**item, "suggested": hint, "gone": gone}) + return Parts(read, suggested, others) + + +class Refused(ValueError): + """What the settings page's forms send is not acceptable. `reason` is "form" (a form that the page never sends), + "none" (a button for the ticked ones, with none ticked) or "project" (a project that is not one: `typed` has what + the owner wrote next to each container, `wrong` the first name that is not a project). The text that it gives + back is cut at the length of a repository's name: nothing longer is a project, and the page shows it again.""" + + def __init__(self, reason: str, typed: Mapping[str, str] | None = None, wrong: str = "") -> None: + super().__init__(reason) + self.reason, self.wrong = reason, wrong[:MAX_PROJECT] + self.typed = {name: text[:MAX_PROJECT] for name, text in (typed or {}).items()} + + +def apply_form( + form: Mapping[str, str], + ticked: Collection[str], + choices: Mapping[str, str], + repos: Sequence[str], + valid: Callable[[str], bool], +) -> dict[str, str]: + """The owner's choices after one of the settings page's forms, for containers or for sources in Loki. Each row + of a form is `name-N`, and, for a project, `project-N`; `pick` says what the button asks for: + + - `selected`, `suggested`: read the rows that are ticked (`ticked` holds their N), or all of them, for the project + that the page showed in `project-N`; + - `skip`: say no to the ticked rows, so that they are not suggested again; + - `stop`: say no to the rows, which were read; + - anything else: the rows with a project typed in `project-N`. An empty one changes nothing, so that saving never + erases a choice that the page shows apart (a project that was removed). + + A project is checked against `repos`, whatever the case, and written as the Projects page writes it. Nothing is + changed unless every row is acceptable: `Refused` says why.""" + rows = [] + for key, name in form.items(): + if key.startswith("name-"): + if not valid(name): + raise Refused("form") + rows.append((key.removeprefix("name-"), name)) + pick, chosen = form.get("pick", ""), dict(choices) + if pick in ("selected", "suggested", "skip", "stop"): + picked = [(n, name) for n, name in rows if pick in ("suggested", "stop") or n in ticked] + if not picked: + raise Refused("none") + for n, name in picked: + if pick in ("skip", "stop"): + chosen[name] = "" + continue + project = project_named(form.get("project-" + n, ""), repos) + if project is None: # the page showed a project for each suggestion: a form that says another is forged + raise Refused("form") + chosen[name] = project + return chosen + typed = {name: text for n, name in rows if (text := form.get("project-" + n, "").strip())} + for name, text in typed.items(): + project = project_named(text, repos) + if project is None: + raise Refused("project", typed, text) + chosen[name] = project + return chosen + + def names_for(repo: str, listed: Iterable[Mapping[str, Any]], choices: Mapping[str, str]) -> list[str]: """The containers of a project, by name.""" return [str(c["name"]) for c in listed if project_of(c, choices).lower() == repo.lower()][:MAX_CONTAINERS] diff --git a/src/lastcall/tiers.py b/src/lastcall/tiers.py index 181e128..299e19d 100644 --- a/src/lastcall/tiers.py +++ b/src/lastcall/tiers.py @@ -8,19 +8,31 @@ Tier = Literal["fast", "balanced", "deep"] TIERS: tuple[Tier, ...] = ("fast", "balanced", "deep") +SECTIONS = ("_about", "models", "efforts") # the keys of tiers.json that are not an engine's tiers def defaults(engine: str) -> dict[str, tuple[str, str | None]]: """tier -> (model, effort), from lastcall/data/tiers.json.""" data = _data() - if engine not in data or engine == "efforts": + if engine not in data or engine in SECTIONS: raise ValueError(f"no default tiers for engine {engine!r}") return {tier: (str(model), str(effort) if effort else None) for tier, (model, effort) in data[engine].items()} +def models(engine: str) -> tuple[str, ...]: + """The model names that an engine's CLI takes, which the web UI suggests for a tier's model; none for an engine + with no list (an API key's models come from models.dev, `lastcall.catalog`).""" + return _listed("models", engine) + + def efforts(engine: str) -> tuple[str, ...]: """The effort levels that an engine's CLI takes, the lowest first; none for an engine that takes none.""" - return tuple(str(level) for level in _data()["efforts"].get(engine, ())) + return _listed("efforts", engine) + + +def _listed(section: str, engine: str) -> tuple[str, ...]: + names = _data()[section].get(engine, ()) if engine != "_about" else () + return tuple(str(name) for name in names) def _data() -> dict[str, Any]: diff --git a/src/lastcall/web/__init__.py b/src/lastcall/web/__init__.py index eeb34f5..0cf8eea 100644 --- a/src/lastcall/web/__init__.py +++ b/src/lastcall/web/__init__.py @@ -32,7 +32,7 @@ from flask.typing import ResponseReturnValue from werkzeug.middleware.proxy_fix import ProxyFix -from lastcall import __version__, cron, destinations, engines, i18n, logs, loki, projects, signals +from lastcall import __version__, catalog, cron, destinations, engines, i18n, logs, loki, projects, signals from lastcall.accounts import ACCOUNT_ID, MODEL, Account, policies, tier_models from lastcall.engines.base import Keyed from lastcall.github import PERMISSIONS, App, Fetch, GitHub, GitHubError, lacking, permissions_of, urlopen @@ -45,6 +45,7 @@ from lastcall.store import AccountRow, Store from lastcall.supervisor import STEP from lastcall.tiers import TIERS, defaults, efforts +from lastcall.tiers import models as cli_models from lastcall.web import auth, dashboard, project_views COOKIE = "lastcall_session" @@ -66,6 +67,10 @@ RETRY_SECONDS = 60.0 # a gap (the owner is fixing it) or a miss is kept only a minute, so that the notice goes soon GRANT_TIMEOUT = 5 # seconds that the Projects page waits for GitHub's answer on one installation MAX_GRANTS = 100 +CATALOG_SECONDS = 86_400.0 # the models that models.dev lists for an API's provider, kept a day: they weigh 6 MB +CATALOG_RETRY_SECONDS = 600.0 # a miss is kept 10 minutes, so that a models.dev that is down costs one wait +CATALOG_TIMEOUT = 5 # seconds that the Accounts page waits for models.dev +KEYS_LIST = "models-keys" # the datalist of the form that adds an API key: the models of every provider MAX_ADDED = 100_000 # the most that a "N projects added" notice says: the number is in the address CREDENTIAL = re.compile(r"(?i)\b(bearer|basic)\s+[^\"']*|\b(authorization|header value)\W+.*") QUICKSTART = "https://github.com/ne0x86/lastcall#-quickstart" @@ -109,8 +114,9 @@ def create_app( """The web UI's application. `github` gives the GitHub client (by default, from the App in the secret store); `background` runs slow work, such as reading the installed repositories, outside the request. `read_quota` reads an account's quota in the sandbox; without it, the web UI cannot check a new account's login. - `exchange` turns GitHub's one-time code into the App's credentials; `fetch` asks Telegram for a bot's chats, and - GitHub whether an owner is a person or an organization, and what the App's installation was granted.""" + `exchange` turns GitHub's one-time code into the App's credentials; `fetch` asks Telegram for a bot's chats, + GitHub whether an owner is a person or an organization, and what the App's installation was granted, and + models.dev which models an API key's provider offers.""" app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 # forms are small if trust_proxy: @@ -125,7 +131,8 @@ def create_app( app_states: dict[str, float] = {} # manifest forms shown, by state, with when they were shown owner_answers: dict[str, tuple[str, float]] = {} # what GitHub said an owner is ("user", "org", "missing"), when grant_answers: dict[str, tuple[str | None, float]] = {} # permissions_page of an owner's installation, and when - cache_lock = threading.Lock() # for app_states, owner_answers and grant_answers: waitress answers on 8 threads + catalog_answers: dict[str, tuple[tuple[str, ...], float]] = {} # a provider's models in models.dev, until when + cache_lock = threading.Lock() # for the four above: waitress answers on 8 threads quota_errors: dict[str, tuple[float, str]] = {} # the last failed quota reading of each account, and when def connect() -> GitHub | None: @@ -280,13 +287,30 @@ def service(action: str) -> ResponseReturnValue: @app.get("/projects") @signed_in def projects_page() -> ResponseReturnValue: - return show_projects(query=request.args.get("q", "")) + args = request.args + return show_projects(query=args.get("q", ""), search=args.get("search", ""), show=args.get("show", "all")) - def show_projects(status: int = 200, error: str = "", install: str = "", query: str = "") -> ResponseReturnValue: - """The Projects page. `query` is what its filter looks for in the suggestions' names.""" + def show_projects( + status: int = 200, + error: str = "", + install: str = "", + query: str = "", + search: str = "", + show: str = "all", + ) -> ResponseReturnValue: + """The Projects page. `query` is what its filter looks for in the suggestions' names; `search` and `show` are + what the owner looks for in the projects' names, and which filter of the cards (project_views.FILTERS).""" found = App.load(secrets) view = project_views.build( - store, g.lang, clock(), found.install_url if found else "", reading.is_set(), permissions_link, query + store, + g.lang, + clock(), + found.install_url if found else "", + reading.is_set(), + permissions_link, + query, + search, + show, ) view.tracker = signals.Tracker.load(secrets) is not None done = request.args.get("done", "") @@ -341,6 +365,32 @@ def permissions_link(repo: str) -> str: del grant_answers[next(iter(grant_answers))] return found or "" + def catalog_models(provider: str) -> tuple[str, ...]: + """The models that models.dev lists for a provider of API keys (its id there), from the last day when it was + asked: the Accounts page asks on every load, and the answer weighs 6 MB. One request answers for every + provider. A failure is kept 10 minutes, so that a models.dev that is down costs the page one wait in that + time, not one at every load; meanwhile it suggests no models. Bounded: one entry for each provider that an + engine offers, and `catalog.MAX_MODELS` models in each.""" + asked = catalog_providers() + if provider not in asked: + return () + now = clock() + with cache_lock: + known = catalog_answers.get(provider) + if known is not None and now < known[1]: + return known[0] + try: + found = catalog.models(fetch, asked, CATALOG_TIMEOUT) + until = now + CATALOG_SECONDS + except Exception as e: # an optional lookup never takes the page down + log(f"Web UI: models.dev gave no models: {brief(e)}") + found, until = {}, now + CATALOG_RETRY_SECONDS + with cache_lock: + for name in asked: + catalog_answers.pop(name, None) # asked again: it goes to the end, as the newest + catalog_answers[name] = (found.get(name, ()), until) + return found.get(provider, ()) + @app.post("/projects/add") @signed_in def add_project() -> ResponseReturnValue: @@ -548,8 +598,16 @@ def show_settings( tried: Mapping[str, str] | None = None, loki_error: str = "", loki_tried: Mapping[str, str] | None = None, + logs_error: str = "", + logs_typed: Mapping[str, str] | None = None, + loki_typed: Mapping[str, str] | None = None, ) -> ResponseReturnValue: + """The Settings page. A mistake in one of the forms of the lists of containers or sources says it in that + section (`logs_error`, `loki_error`) and gives back the projects that the owner typed there (`logs_typed`, + `loki_typed`). The filters by name of the two lists are in the address (`logs_q`, `loki_q`).""" chosen, filled = notifications.events(), dict(values or {}) + logs_q = project_views.clean_query(request.args.get("logs_q", "")) + loki_q = project_views.clean_query(request.args.get("loki_q", "")) language = ( notifications.language() if store.setting("notify_language") else g.lang ) # until saved: this page's @@ -572,8 +630,8 @@ def show_settings( "chats": list(chats), "fanout": store.setting("fanout") == "yes", "tracker": tracker, - "logs": containers_view(), - "loki": loki_view(loki_tried), + "logs": containers_view(logs_q, logs_typed), + "loki": loki_view(loki_tried, loki_q, loki_typed), "signals_url": (tried or {}).get("url") or (tracker.url if tracker else "https://sentry.io"), "signals_org": (tried or {}).get("org") or (tracker.org if tracker else ""), } @@ -595,6 +653,7 @@ def show_settings( signals_error=signals_error, sentry_tokens=SENTRY_TOKENS, loki_error=loki_error, + logs_error=logs_error, ) @app.post("/settings/signals") @@ -619,44 +678,56 @@ def connect_signals() -> ResponseReturnValue: log(f"Web UI: errors in production read from {url}, organization {org}") return redirect(url_for("settings_page", done="signals") + "#signals") + def refusal(refused: logs.Refused, kind: str) -> ResponseReturnValue: + """The answer to a form of a list of containers (`kind` "logs") or of sources in Loki ("loki") that + `logs.apply_form` refuses: an error page for a form that the page never sends, else the Settings page, with + the mistake said in the section and what the owner typed in its place.""" + if refused.reason == "form": + return problem(400, "web.error.form") + if refused.reason == "none": + message = t(f"web.{kind}.none_ticked") + else: + message = t("web.logs.not_a_project", project=refused.wrong) + if kind == "logs": + return show_settings(400, logs_error=message, logs_typed=refused.typed) + return show_settings(400, loki_error=message, loki_typed=refused.typed) + @app.post("/settings/logs") @signed_in def logs_settings() -> ResponseReturnValue: - """The project of each container, as the owner chooses it: lastcall reads the containers that have one.""" - form, repos = request.form, {p.repo for p in store.projects()} - choices = logs.chosen(store) - for key, name in form.items(): - if not key.startswith("name-"): - continue - index = key.removeprefix("name-") - project = form.get("project-" + index, "") - if not CONTAINER.fullmatch(name) or project not in {"", *repos}: - return problem(400, "web.error.form") - if project or form.get("shown-" + index): # a project, or a no to the one that the page showed - choices[name] = project + """What the forms of the Container logs section ask (`logs.apply_form`): to read the ticked suggestions, or + all of them, to skip the ticked ones, to stop reading one, or to read the containers that got a project in + the fold of the others. The project of each is checked against the projects, whatever its case.""" + form = request.form + try: + choices = logs.apply_form( + form, + form.getlist("tick"), + logs.chosen(store), + [p.repo for p in store.projects()], + lambda name: bool(CONTAINER.fullmatch(name)) and not name.startswith(STEP), # never a step's + ) + except logs.Refused as refused: + return refusal(refused, "logs") logs.choose(store, choices) log(f"Web UI: containers read for their projects: {', '.join(sorted(k for k, v in choices.items() if v))}") return redirect(url_for("settings_page", done="logs") + "#logs") - def containers_view() -> dict[str, Any]: - """The containers of this host, each with its project, or the one lastcall suggests.""" + def containers_view(query: str = "", typed: Mapping[str, str] | None = None) -> dict[str, Any]: + """The containers of this host in the three parts that the page shows (`logs.parts`).""" if containers is None: - return {"error": t("web.logs.unavailable"), "rows": [], "repos": []} + return {**lists_view(logs.Parts([], [], []), [], query, {}), "error": t("web.logs.unavailable")} try: listed = containers() except Exception as e: # an optional integration never takes the page down known = isinstance(e, (SandboxError, OSError)) if not known: log(f"Web UI: the containers could not be listed: {brief(e)}") - return {"error": t("web.logs.failed", error=str(e)[:200] if known else brief(e)), "rows": [], "repos": []} - repos, choices = [p.repo for p in store.projects()], logs.chosen(store) - rows = [] - for c in listed: - name = str(c.get("name") or "") - choice, gone = logs.shown(choices.get(name, ""), repos) - hint = "" if c.get("label") or name in choices else logs.suggest(c, repos) - rows.append({**c, "selected": choice or hint, "suggested": hint, "gone": gone}) - return {"error": "", "rows": rows, "repos": repos} + failed = t("web.logs.failed", error=str(e)[:200] if known else brief(e)) + return {**lists_view(logs.Parts([], [], []), [], query, {}), "error": failed} + repos = [p.repo for p in store.projects()] + found = logs.parts(listed, repos, logs.chosen(store)) + return {**lists_view(found, repos, query, typed or {}), "error": ""} @app.post("/settings/loki") @signed_in @@ -697,21 +768,22 @@ def loki_label() -> ResponseReturnValue: @app.post("/settings/loki/sources") @signed_in def loki_sources() -> ResponseReturnValue: - """The project of each source in Loki, as the owner chooses it: lastcall reads the sources that have one.""" - form, repos = request.form, {p.repo for p in store.projects()} + """What the forms of the sources in Loki ask, as the containers' do (`logs_settings`): lastcall reads the + sources that have a project. Each form says the label that names the sources: the choices belong to it.""" + form = request.form label = form.get("label", "") if not loki.LABEL_NAME.fullmatch(label): return problem(400, "web.error.form") - choices = loki.chosen(store) if label == store.setting(loki.LABEL) else {} - for key, name in form.items(): - if not key.startswith("name-"): - continue - index = key.removeprefix("name-") - project = form.get("project-" + index, "") - if not loki.SOURCE.fullmatch(name) or name.startswith(STEP) or project not in {"", *repos}: - return problem(400, "web.error.form") - if project or form.get("shown-" + index): # a project, or a no to the one that the page showed - choices[name] = project + try: + choices = logs.apply_form( + form, + form.getlist("tick"), + loki.chosen(store) if label == store.setting(loki.LABEL) else {}, + [p.repo for p in store.projects()], + lambda name: bool(loki.SOURCE.fullmatch(name)) and not name.startswith(STEP), + ) + except logs.Refused as refused: + return refusal(refused, "loki") store.set_setting(loki.LABEL, label) loki.choose(store, choices) read = ", ".join(sorted(k for k, v in choices.items() if v)) @@ -725,15 +797,18 @@ def disconnect_loki() -> ResponseReturnValue: log("Web UI: Loki disconnected") return redirect(url_for("settings_page", done="loki_removed") + "#loki") - def loki_view(tried: Mapping[str, str] | None) -> dict[str, Any]: - """The connection to Loki, and its sources, each with its project, or the one lastcall suggests.""" + def loki_view( + tried: Mapping[str, str] | None, query: str = "", typed: Mapping[str, str] | None = None + ) -> dict[str, Any]: + """The connection to Loki, and its sources in the three parts that the page shows (`logs.parts`).""" connection, tried = loki.Loki.load(secrets), tried or {} view: dict[str, Any] = { "connection": connection, "url": tried.get("url") or (connection.url if connection else ""), "user": tried.get("user") or (connection.user if connection else ""), "tenant": tried.get("tenant") or (connection.tenant if connection else ""), - **{"error": "", "labels": [], "label": "", "rows": [], "repos": []}, + **lists_view(logs.Parts([], [], []), [], query, {}), + **{"error": "", "labels": [], "label": ""}, } if connection is None: return view @@ -747,13 +822,8 @@ def loki_view(tried: Mapping[str, str] | None) -> dict[str, Any]: if not known: log(f"Web UI: Loki's sources could not be listed: {brief(e)}") return {**view, "error": t("web.loki.sources_failed", error=str(e)[:200] if known else brief(e))} - rows = [] - for s in listed: - name = s["name"] - choice, gone = logs.shown(choices.get(name, ""), repos) - hint = "" if name in choices else logs.suggest(s, repos) - rows.append({**s, "selected": choice or hint, "suggested": hint, "gone": gone}) - return {**view, "labels": names, "label": label, "rows": rows, "repos": repos} + found = logs.parts(listed, repos, choices) + return {**view, **lists_view(found, repos, query, typed or {}), "labels": names, "label": label} @app.post("/settings/signals/remove") @signed_in @@ -951,13 +1021,23 @@ def show_accounts( message = t(f"web.done.{done}") if done in known and not error else "" all_engines = [engines.get(name) for name in sorted(engines.BUILTIN)] sources = [(e, p) for e in all_engines if isinstance(e, Keyed) for p in e.providers] + lists: dict[str, tuple[str, ...]] = {} # the datalists of the page, by id: what its model fields suggest + cards = [account_card(row, now, working, lists) for row in store.accounts()] + keys = [(f"{e.name}/{p.name}", p) for e, p in sources if p.keys] + # Without a script, the form that adds an API key cannot follow the provider that it has chosen: its model + # fields suggest the models of every provider, and OpenRouter writes the maker first (qwen/qwen3-coder). + everyone = {name for _, p in keys for name in catalog_models(p.catalog)} + if everyone: + lists[KEYS_LIST] = tuple(sorted(everyone, key=lambda name: (name.casefold(), name))) return page( "accounts.html", status, - cards=[account_card(row, now, working) for row in store.accounts()], + cards=cards, held="down" if down else ("paused" if paused else ""), engines=[(e.name, f"{e.provider} ({e.title})") for e in all_engines if e.login], - keys=[(f"{e.name}/{p.name}", p) for e, p in sources if p.keys], + keys=keys, + datalists=lists, + key_list=KEYS_LIST if everyone else "", local=next(((f"{e.name}/{p.name}", p) for e, p in sources if not p.keys), None), writing=[(name, dashboard.language_name(g.lang, name).capitalize()) for name in project_views.LANGUAGES], known=project_views.LANGUAGES, @@ -993,7 +1073,32 @@ def quota_error(account_id: str) -> str: return "" return failed[1] - def account_card(row: AccountRow, now: float, working: Mapping[str, dict[str, Any]]) -> dict[str, Any]: + def suggestions(row: AccountRow, lists: dict[str, tuple[str, ...]]) -> dict[str, str]: + """The datalist that suggests models in the fields of an account's tiers, and the note that says where its + names come from: for an account on a CLI login, the names that the CLI takes, from lastcall's data file; for + an account on an API key, what models.dev lists for its provider. Empty when there is nothing to suggest: a + local model server (the web UI cannot reach it), a models.dev that did not answer, an engine that is gone.""" + if row.engine not in engines.BUILTIN: + return {} + engine = engines.get(row.engine) + if not row.keyed: + names, key, note = cli_models(row.engine), row.engine, t("web.tiers.suggest.cli", tool=engine.title) + elif isinstance(engine, Keyed): + source = next((p for p in engine.providers if p.name == row.provider), None) + if source is None: + return {} + names, key = catalog_models(source.catalog), f"{row.engine}-{source.name}" + note = t("web.tiers.suggest.catalog", provider=source.title) + else: + return {} + if not names: + return {} + lists[f"models-{key}"] = names + return {"list": f"models-{key}", "note": note} + + def account_card( + row: AccountRow, now: float, working: Mapping[str, dict[str, Any]], lists: dict[str, tuple[str, ...]] + ) -> dict[str, Any]: try: base = defaults(row.engine) except ValueError: # an engine with no defaults, such as opencode: the account names its models @@ -1008,6 +1113,7 @@ def account_card(row: AccountRow, now: float, working: Mapping[str, dict[str, An "policies": [], "needs_key": False, "levels": efforts(row.engine), + "suggest": suggestions(row, lists), "tiers": [ { "tier": tier, @@ -1236,6 +1342,16 @@ def not_found(_: Exception) -> ResponseReturnValue: return app +def catalog_providers() -> tuple[str, ...]: + """The ids in models.dev of the providers that the engines on API keys offer: what the web UI asks it about.""" + found: list[str] = [] + for name in sorted(engines.BUILTIN): + engine = engines.get(name) + if isinstance(engine, Keyed): + found += [p.catalog for p in engine.providers if p.catalog] + return tuple(dict.fromkeys(found)) + + def brief(error: BaseException) -> str: """An exception for the log: its type and a short line of its message. Never its repr, which can hold request data, and a header's value or an authorization is masked wherever it shows in the message.""" @@ -1243,6 +1359,25 @@ def brief(error: BaseException) -> str: return f"{type(error).__name__}: {message[:120]}" if message else type(error).__name__ +def lists_view(found: logs.Parts, repos: Sequence[str], query: str, typed: Mapping[str, str]) -> dict[str, Any]: + """What the template needs of a list of containers or sources in three parts: those that are read, those that + are suggested, and the others that the filter by name leaves, each with what the owner typed next to it before a + mistake in the form. The fold of the others says how many there are in all (`total`) and offers the filter once + there are many of them or while one is set.""" + others = [{**o, "typed": typed.get(str(o["name"]), "")} for o in found.others_named(query)] + return { + "read": found.read, + "suggested": found.suggested, + "others": others, + "total": len(found.others), + "count": found.count, + "query": query, + "typed": bool(typed), + "filterable": len(found.others) > project_views.MANY or bool(query), + "repos": list(repos), + } + + def language() -> str: chosen = request.cookies.get(LANGUAGE_COOKIE, "") if chosen in i18n.CATALOGS: diff --git a/src/lastcall/web/project_views.py b/src/lastcall/web/project_views.py index af128bc..b2b6630 100644 --- a/src/lastcall/web/project_views.py +++ b/src/lastcall/web/project_views.py @@ -17,6 +17,9 @@ LANGUAGES = ("English", "Spanish") MAX_PRS = range(1, 6) MAX_QUERY = 100 # characters of what the filter of the suggestions looks for: a repository's name has 140 at most +MANY = 6 # with more than this, lists get a filter by name (the suggestions, the projects, the settings' lists) and a +# project's Run now starts folded +FILTERS = ("all", "active", "inactive", "waiting") # what the filter links of the cards show # what a project's card says while an order of each recipe waits, or while a run of it works QUEUED = { "audit": "web.project.queued", @@ -81,12 +84,20 @@ class SuggestionView: left_out: bool = False # archived, a fork or empty: "Add all" skips it +@dataclass(frozen=True) +class FilterLink: + key: str # one of FILTERS + label: str + count: int # the projects that it shows, for the current search + current: bool + + @dataclass class ProjectsPage: github_ready: bool install_url: str refreshed: str - projects: list[ProjectCard] = field(default_factory=list) + projects: list[ProjectCard] = field(default_factory=list) # what the search and the filter leave accounts: list[Option] = field(default_factory=list) suggested: list[SuggestionView] = field(default_factory=list) # what the filter shows others: list[SuggestionView] = field(default_factory=list) @@ -94,6 +105,20 @@ class ProjectsPage: tracker: bool = False # the owner connected an error tracker: each project may name its project there query: str = "" # what the filter looks for in their names; "" for no filter total: int = 0 # the repositories that are not projects yet, before the filter + known: int = 0 # how many projects there are, whatever the search and the filter + search: str = "" # what the search looks for in the names of the projects; "" for none + show: str = "all" # the filter of the cards: one of FILTERS + filters: list[FilterLink] = field(default_factory=list) + + @property + def many(self) -> bool: + """Whether there are so many projects that each card starts with its Run now folded.""" + return self.known > MANY + + @property + def can_search(self) -> bool: + """Whether the page offers the search and the filters: with many projects, or while one of them is set.""" + return self.many or bool(self.search) or self.show != "all" @property def has_all(self) -> bool: @@ -119,15 +144,26 @@ def build( reading: bool, permissions_url: Callable[[str], str], query: str = "", + search: str = "", + show: str = "all", ) -> ProjectsPage: """`permissions_url` says, for a repository, where its owner gives the App what reading checks takes: "" when the - App has it already, or when that is not known. It is asked only for the projects that read checks. `query` is - what the filter looks for in the names of the suggestions.""" + App has it already, or when that is not known. It is asked only for the cards that show, and for the projects that + read checks. `query` is what the filter looks for in the names of the suggestions; `search` and `show` are what + the owner looks for in the names of the projects, and which of FILTERS to show.""" projects = store.projects() busy = {o["repo"].lower(): text(lang, QUEUED.get(o["kind"], QUEUED["audit"])) for o in store.orders()} busy |= {r["repo"].lower(): text(lang, RUNNING.get(r["recipe"], RUNNING["audit"])) for r in store.running()} latest = store.latest_runs() page = ProjectsPage(bool(install_url), install_url, refreshed(store, lang, now, reading)) + search, show = clean_query(search), clean_show(show) + page.known, page.search, page.show = len(projects), search, show + waiting = waiting_for_the_owner(store) + named = [p for p in projects if search.casefold() in p.repo.casefold()] + counts = {key: sum(passes(key, p, waiting) for p in named) for key in FILTERS} + page.filters = [ + FilterLink(key, text(lang, f"web.projects.filter.{key}"), counts[key], key == show) for key in FILTERS + ] page.projects = [ card( store, @@ -138,7 +174,8 @@ def build( busy.get(p.repo.lower(), ""), permissions_url(p.repo) if p.merge_on_approve or p.watch else "", ) - for p in projects + for p in named + if passes(show, p, waiting) ] enabled = store.accounts(enabled_only=True) page.accounts = [Option("", text(lang, "web.project.any"), True)] @@ -163,10 +200,39 @@ def left_to_add(projects: Iterable[ProjectRow], found: Iterable[Suggestion]) -> def clean_query(typed: str) -> str: - """What the filter of the suggestions looks for: the text without the spaces around it, cut at 100 characters.""" + """What a filter or a search looks for: the text without the spaces around it, cut at 100 characters.""" return typed.strip()[:MAX_QUERY] +def clean_show(typed: str) -> str: + """The filter of the cards that an address asks for: one of FILTERS, and "all" for any other value.""" + return typed if typed in FILTERS else "all" + + +def is_active(p: ProjectRow) -> bool: + """Whether bursts may pick the project: its flag is on and it has a priority (a weight of 0 or less is off).""" + return p.active and p.weight > 0 + + +def waiting_for_the_owner(store: Store) -> set[str]: + """The projects, in lower case, that wait for the owner: they have pull requests to review, decisions in an issue, + or ideas to choose in a plan, as the dashboard lists them under Waiting for you.""" + found = {pr["repo"] for pr in store.open_prs()} | {d["repo"] for d in store.decisions()} + return {repo.lower() for repo in found | {plan["repo"] for plan in store.plans()}} + + +def passes(show: str, p: ProjectRow, waiting: set[str]) -> bool: + """Whether the filter `show` leaves the project.""" + match show: + case "active": + return is_active(p) + case "inactive": + return not is_active(p) + case "waiting": + return p.repo.lower() in waiting + return True + + def addable(found: Iterable[Suggestion]) -> list[Suggestion]: """What "Add all" adds of these suggestions: not the archived ones, the forks or the empty ones.""" return [s for s in found if not s.left_out] @@ -181,7 +247,7 @@ def matching(found: Iterable[Suggestion], query: str) -> list[Suggestion]: def card( store: Store, p: ProjectRow, lang: str, now: float, last: dict[str, Any] | None, busy: str, permissions_url: str ) -> ProjectCard: - active = p.active and p.weight > 0 + active = is_active(p) view = project_view(store, p.repo, active, p.weight, lang, now, last) waiting = store.waiting_count(p.repo) weights = [Option(v, text(lang, f"web.project.weight.{k}"), float(v) == p.weight) for v, k in WEIGHTS] diff --git a/src/lastcall/web/static/style.css b/src/lastcall/web/static/style.css index c462e13..4253cb8 100644 --- a/src/lastcall/web/static/style.css +++ b/src/lastcall/web/static/style.css @@ -913,7 +913,32 @@ ul.rows > li:last-child { border-bottom: 0; } .row-actions { display: flex; flex-wrap: wrap; gap: var(--s2); } .row-actions form { margin: 0; } .source-name { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); margin: 0; } -.sources select { margin-top: var(--s1); } +.source-name strong { overflow-wrap: anywhere; } + +/* A long list of containers or sources in three parts: what is read comes first, as short rows with a way to stop it; the + suggestions are the Projects page's tiles (ul.suggestions); the rest is a fold with a filter by name and, for each one, + a field whose datalist narrows the projects as you type. */ +.parts { display: grid; gap: var(--s4); margin: var(--s3) 0; } +.part-title { margin: 0 0 var(--s2); font-family: var(--font-round); font-size: 1.02rem; font-weight: 700; } +.part > .section-head .part-title { margin: 0; } +.part > p { margin: 0; } +ul.reading { margin: 0; } +ul.reading > li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s2) var(--s3); } +.read-main { flex: 1 1 9rem; min-width: 0; } +.read-main p { margin: 0; } +.read-main .hint { margin-top: var(--s1); } +.read-project { overflow-wrap: anywhere; } +.reading .row-actions { flex: none; } +.others-fold { margin: 0; } +.others-form { display: grid; gap: var(--s3); margin: 0; } +.others-form > button { justify-self: start; } +ul.others { margin: 0; } +ul.others > li { grid-template-columns: minmax(0, 1fr); align-items: start; } +.other-main > p { margin: 0; } +.other-main > .hint { margin-top: var(--s1); } +@media (min-width: 48rem) { + ul.others > li { grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem); align-items: center; gap: var(--s1) var(--s3); } +} /* ---------- Settings ---------- */ .settings-layout { display: grid; gap: var(--s4); } @@ -1111,6 +1136,7 @@ fieldset.has-help > legend { float: left; margin: 0; } .project-settings fieldset { gap: var(--s3); padding: var(--s3); background: var(--surface); border-radius: var(--r-md); } .project-settings .check-row { min-height: 0; } .project-settings .check-row .hint { margin-top: 0; } +.project-settings .fields { grid-template-columns: minmax(0, 1fr); } /* a select a row: a closed select cuts its text short */ /* Add a project: the manual field, then suggestions as rows. */ .add-card .inline { margin-bottom: var(--s2); } @@ -1173,6 +1199,43 @@ ul.suggestions li:has(input:checked) .help { background: var(--surface); } } html:has(.pick-bar) { scroll-padding-bottom: 6rem; } /* a field that takes focus is never behind the bar */ +/* Search and filters above the cards, from the seventh project on: the search is the suggestions' filter, and each link is a + pill with how many projects it leaves for the current search. The current one says so with a tick, not only a colour. */ +.find { display: grid; gap: var(--s2); margin: 0 0 var(--s4); } +.find .filter, .find .filter-note { margin: 0; } +.find .filter { max-width: 36rem; } +.find-links ul { display: flex; flex-wrap: wrap; gap: var(--s2); margin: 0; padding: 0; list-style: none; } +.find-links a { + display: inline-flex; + align-items: center; + gap: .5rem; + min-height: 44px; + padding: 0 var(--s3); + color: var(--text); + font-weight: 650; + text-decoration: none; + background: var(--surface); + border: 1.5px solid var(--line); + border-radius: var(--r-pill); +} +.find-links a:hover { border-color: var(--accent); background: var(--accent-soft); } +.find-links a[aria-current] { background: var(--accent-soft); border-color: var(--accent); } +.find-links a[aria-current]::before { content: "\2713"; color: var(--accent); } +.find-links .count { + min-width: 1.5rem; + padding: 0 .4rem; + color: var(--muted); + font-size: .85rem; + font-variant-numeric: tabular-nums; + text-align: center; + background: var(--raised); + border-radius: var(--r-pill); +} +.find-links a[aria-current] .count { color: var(--on-accent); background: var(--accent); } + +/* With many projects, Run now is a fold, as Settings is: its form sits in it as the group's own panel. */ +.run-fold > .run-form { margin: 0 0 var(--s3); } + /* ---------- Sign-in pages ---------- */ .signed-out main { display: grid; align-content: start; } .sign-card { diff --git a/src/lastcall/web/templates/_lists.html b/src/lastcall/web/templates/_lists.html new file mode 100644 index 0000000..bb925dc --- /dev/null +++ b/src/lastcall/web/templates/_lists.html @@ -0,0 +1,148 @@ +{#- A long list of containers (Settings › Container logs) or of sources in Loki (Settings › Logs in Loki), in three parts so + that it stays short: what lastcall reads now, what it suggests, and everything else in a fold with a filter by name. + Import with context, after _help.html's help macro: `lists` takes the page's help namespace `hb`. + `kind` is "logs" or "loki": it names the ids, the filter's address parameter (`logs_q`, `loki_q`) and the catalog + keys that differ. `v` is the view of the list (web.lists_view). `action` is where its forms post, `extra` the hidden + fields that each of them carries (the label that names the sources), `carry` the other list's filter, kept by this + list's own. `level` is the heading of the parts. -#} +{% from "_help.html" import help with context %} + +{#- The name of a container or a source, with its state or the machines where it ran. -#} +{% macro identity(item) -%} +

{{ item.name }} + {%- if item.state %} {{ item.state }}{% endif %} + {%- for host in item.hosts %} {{ host }}{% endfor %}

+{%- endmacro %} + +{#- What helps to tell it from the others: a source's Compose project, a container's image. -#} +{% macro clue(item, kind) -%} +{%- if kind == "loki" and item.compose_project %}

{{ t("web.loki.compose", project=item.compose_project) }}

+{%- elif kind == "logs" and item.image %}

{{ item.image }}

{% endif %} +{%- endmacro %} + +{% macro hidden(extra) -%} + +{%- for name, value in extra %} + +{%- endfor %} +{%- endmacro %} + +{% macro lists(hb, kind, v, action, level="h4", extra=(), carry=()) %} +{%- set post = action ~ "#" ~ kind %} +
+
+ <{{ level }} class="part-title" id="{{ kind }}-read-title">{{ t("web.logs.read_title") }} + {%- if v.read %} +
    + {%- for r in v.read %} +
  • +
    + {{ identity(r) }} +

    {{ r.project }} · {{ t("web.logs.label_decides") if r.by == "label" else t("web.logs.chosen_here") }}

    + {%- if r.unknown %} +

    {{ t("web.logs.label_unknown", project=r.project) }}

    + {%- endif %} + {%- if kind == "loki" %}{{ clue(r, kind) }}{% endif %} +
    + {%- if r.by == "choice" %} +
    + {{ hidden(extra) }} + + + +
    + {%- endif %} +
  • + {%- endfor %} +
+ {%- else %} +

{{ t("web.logs.read_none") }}

+ {%- endif %} +
+ + {%- if v.suggested %} +
+
<{{ level }} class="part-title" id="{{ kind }}-suggested-title">{{ t("web.logs.suggested_title") }}{{ help(hb, "settings-logs_suggested", t("web.logs.suggested_title"), ["settings.logs_suggested"], true) }}
+
+ {{ hidden(extra) }} +
+ + +
+
+
    + {%- for s in v.suggested %} + {%- set n = loop.index %} +
  • +
    + + +
    +

    {{ t("web.logs.for_project", project=s.suggested) }} + {%- if s.state %} {{ s.state }}{% endif %} + {%- for host in s.hosts %} {{ host }}{% endfor %}

    + {%- if kind == "loki" %}{{ clue(s, kind) }}{% endif %} + + +
  • + {%- endfor %} +
+
+ {#- Only the main action stays at the bottom of the screen, so that the bar is one button high on a phone. -#} +
+ +
+
+
+ {%- endif %} + + {%- if v.total %} +
+ {{ tn("web." ~ kind ~ ".others", v.total) }} +
+ {%- if v.filterable %} + + {%- endif %} + {%- if v.query %} +

{{ tn("web.logs.matching", v.total, shown=v.others|length, query=v.query) }} {{ t("web.suggest.show_all") }}

+ {%- endif %} + {%- if v.others %} +
+ {{ hidden(extra) }} +

{{ t("web.logs.others_hint") }}

{{ help(hb, "settings-logs_project", t("web.logs.project"), ["settings.logs_project"], true) }}
+
    + {%- for o in v.others %} + {%- set n = loop.index %} +
  • +
    + {{ identity(o) }} + {{ clue(o, kind) }} + {%- if o.gone %} +

    {{ t("web.logs.gone", project=o.gone) }}

    + {%- endif %} +
    + + +
  • + {%- endfor %} +
+ + {%- for repo in v.repos %}{% endfor %} + + +
+ {%- endif %} +
+
+ {%- endif %} +
+{%- endmacro %} diff --git a/src/lastcall/web/templates/accounts.html b/src/lastcall/web/templates/accounts.html index 7a2cacc..5e1cf8b 100644 --- a/src/lastcall/web/templates/accounts.html +++ b/src/lastcall/web/templates/accounts.html @@ -125,7 +125,7 @@

{{ t("web.nav.accounts") }}

- +
{% if loop.first %}{{ help(hb, "accounts-effort", t("web.tiers.effort"), ["accounts.effort"], true) }}{% endif %}
@@ -138,6 +138,9 @@

{{ t("web.nav.accounts") }}

{%- endfor %}

{{ t("web.tiers.keyed_hint") if row.keyed else t("web.tiers.default_hint") }}

+ {%- if c.suggest %} +

{{ c.suggest.note }}

+ {%- endif %}
@@ -228,12 +231,12 @@

{{ t("web.accounts.kind.key") }}

- -

{{ t("web.accounts.model_hint") }}

+ +

{{ t("web.accounts.model_hint") }}{% if key_list %} {{ t("web.accounts.model_suggest") }}{% endif %}

{{ help(hb, "accounts-review", t("web.accounts.review_label"), ["accounts.review_model"], true) }}
- +

{{ t("web.accounts.review_hint") }}

@@ -311,5 +314,10 @@

{{ t("web.accounts.kind.local") }}

{%- endif %}
+{#- The datalists that the model fields point to (`list`): one for each kind of account on the page, whatever the number of + its cards. A browser shows them as suggestions and lets the owner type any other name. -#} +{%- for list_id, names in datalists.items() %} +{% for name in names %}{% endfor %} +{%- endfor %} {{ bubbles(hb) }} {% endblock %} diff --git a/src/lastcall/web/templates/projects.html b/src/lastcall/web/templates/projects.html index b63480f..6be188b 100644 --- a/src/lastcall/web/templates/projects.html +++ b/src/lastcall/web/templates/projects.html @@ -11,7 +11,7 @@

{{ t("web.projects.title") }}

{{ t("web.projects.intro") }}

- {%- if v.projects %}

{{ t("web.add.title") }}

{% endif %} + {%- if v.known %}

{{ t("web.add.title") }}

{% endif %}
{% if done %}

{{ done }}

{% endif %} @@ -73,10 +73,13 @@

{{ t("web.suggest.title") }}

+ {#- the search and the filter of the cards stay as they are -#} + {%- if v.search %}{% endif %} + {%- if v.show != "all" %}{% endif %} {%- endif %} {%- if v.query %} -

{{ tn("web.suggest.matching", v.total, shown=v.shown, query=v.query) }} {{ t("web.suggest.show_all") }}

+

{{ tn("web.suggest.matching", v.total, shown=v.shown, query=v.query) }} {{ t("web.suggest.show_all") }}

{%- endif %} {%- if v.shown %}
@@ -119,11 +122,39 @@

{{ t("web.suggest.title") }}

{%- endmacro %} -{% if not v.projects %} +{% if not v.known %}

{{ t("web.projects.empty") if v.github_ready else t("web.projects.none") }}

{{ add_section() }} {% else %}
+ {#- With many projects: a search by name and the filters, above the cards. Each link keeps the search and the filter of the + suggestions below, and all of them bring the page back here (#find). Their counts are those of the current search. -#} + {%- if v.can_search %} +
+ + +
+ + +
+ {%- if v.show != "all" %}{% endif %} + {%- if v.query %}{% endif %} + + + {%- if v.search or v.show != "all" %} +

{{ tn("web.projects.showing", v.known, shown=v.projects|length) }}

+ {%- endif %} +
+ {%- endif %} + {%- if not v.projects %} +

{{ t("web.projects.no_match") }} {{ t("web.projects.show_all") }}

+ {%- else %}
{%- for p in v.projects %} {%- set n = loop.index %} @@ -183,12 +214,22 @@

{{ t("web.project.remove_title", repo=p.repo) }}{{ p.busy }}

{%- elif v.accounts|length > 1 %} + {#- With many projects the group starts folded, like the settings below it, so that the cards are short: its title is + the summary that opens it, and its "?" moves inside, after a line that says what the group does. -#} + {%- if v.many %} +
+ {{ t("web.project.run_now") }} + {%- endif %}
-
+
+ {%- if v.many %} +

{{ t("web.project.run_summary") }}

{{ help(hb, "projects-run_now", t("web.project.run_now"), ["projects.run_now"], true) }}
+ {%- else %} {{ t("web.project.run_now") }}{{ help(hb, "projects-run_now", t("web.project.run_now"), ["projects.run_now"]) }} + {%- endif %}
-
{{ help(hb, "projects-account", t("web.project.account"), account_keys) }}
+
{{ help(hb, "projects-account", t("web.project.account"), account_keys, v.many) }}
@@ -200,12 +241,15 @@

{{ t("web.project.remove_title", repo=p.repo) }}{{ t("web.project.deps_now") }}

    -
  • {{ t("web.project.improve_hint") }}

    {{ help(hb, "projects-improve", t("web.project.improve"), ["projects.improve"]) }}
  • +
  • {{ t("web.project.improve_hint") }}

    {{ help(hb, "projects-improve", t("web.project.improve"), ["projects.improve"], v.many) }}
  • {{ t("web.project.tests_hint") }}

  • {{ t("web.project.deps_hint") }}

+ {%- if v.many %} +
+ {%- endif %} {%- else %}

{{ t("web.project.no_accounts") }}

{%- endif %} @@ -233,9 +277,10 @@

{{ t("web.project.remove_title", repo=p.repo) }}
{{ help(hb, "projects-language", t("web.project.language"), ["projects.language"], true) }}
- {%- for o in p.languages %}{% endfor %} +

{{ t("web.project.language_hint") }}

{{ help(hb, "projects-max_prs", t("web.project.max_prs"), ["projects.max_prs"], true) }}
@@ -270,6 +315,7 @@

{{ t("web.project.remove_title", repo=p.repo) }} {%- endfor %}

+ {%- endif %}

{{ add_section() }} {% endif %} diff --git a/src/lastcall/web/templates/settings.html b/src/lastcall/web/templates/settings.html index 4243fef..4feb0de 100644 --- a/src/lastcall/web/templates/settings.html +++ b/src/lastcall/web/templates/settings.html @@ -2,6 +2,7 @@ {% block title %}{{ t("web.settings.title") }}{% endblock %} {% block main %} {%- from "_help.html" import help, bubbles with context %} +{%- from "_lists.html" import lists with context %} {%- set hb = namespace(items=[], seen=[]) %} {%- set event_help = ("decisions", "merged", "reverted", "paid", "quota", "burst") %}
@@ -317,36 +318,15 @@

{{ t("web.logs.title") }}

  • {{ t("web.logs.step1") }}
  • {{ t("web.logs.step2") }}
  • + {%- if logs_error %} + + {%- endif %} {%- if v.logs.error %} - {%- elif not v.logs.rows %} + {%- elif not v.logs.count %}

    {{ t("web.logs.none") }}

    {%- else %} -
    - -
      - {%- for c in v.logs.rows %} -
    • -

      {{ c.name }} {{ c.state }}

      -

      {{ c.image }}

      - {%- if c.label %} -

      {{ t("web.logs.from_label", project=c.label) }}

      - {%- else %} - - -
      {{ help(hb, "settings-logs_project", t("web.logs.project"), ["settings.logs_project"]) }}
      - - {%- if c.suggested %}

      {{ t("web.logs.suggested", project=c.suggested) }}

      {% endif %} - {%- if c.gone %}

      {{ t("web.logs.gone", project=c.gone) }}

      {% endif %} - {%- endif %} -
    • - {%- endfor %} -
    - -
    + {{ lists(hb, "logs", v.logs, url_for('logs_settings'), "h4", (), [("loki_q", v.loki.query)] if v.loki.query else []) }} {%- endif %}
    {{ t("web.logs.label_title") }} @@ -416,32 +396,11 @@

    {{ t("web.logs.title") }}

    - {%- if v.loki.label and not v.loki.rows %} + {%- if v.loki.label and not v.loki.count %}

    {{ t("web.loki.none") }}

    - {%- elif v.loki.rows %} -
    - - -

    {{ t("web.help.settings.loki_sources.title") }}

    {{ help(hb, "settings-loki_sources", t("web.help.settings.loki_sources.title"), ["settings.loki_sources"]) }}
    -
      - {%- for s in v.loki.rows %} -
    • -

      {{ s.name }}{% for host in s.hosts %} {{ host }}{% endfor %}

      - {%- if s.compose_project %}

      {{ t("web.loki.compose", project=s.compose_project) }}

      {% endif %} - - - - - {%- if s.suggested %}

      {{ t("web.logs.suggested", project=s.suggested) }}

      {% endif %} - {%- if s.gone %}

      {{ t("web.logs.gone", project=s.gone) }}

      {% endif %} -
    • - {%- endfor %} -
    - -
    + {%- elif v.loki.count %} +

    {{ t("web.help.settings.loki_sources.title") }}

    {{ help(hb, "settings-loki_sources", t("web.help.settings.loki_sources.title"), ["settings.loki_sources"]) }}
    + {{ lists(hb, "loki", v.loki, url_for('loki_sources'), "h5", [("label", v.loki.label)], [("logs_q", v.logs.query)] if v.logs.query else []) }} {%- endif %} {%- endif %}
    diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..78fac72 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,206 @@ +"""models.dev's catalog, as the web UI reads it: which models a provider offers that a step can run on. The tests +never ask models.dev: `ModelsDev` is a `fetch` that answers from a small catalog of the shape that it has.""" + +import gzip +import json +import urllib.request +import zlib +from collections.abc import Mapping +from typing import Any + +import pytest + +from lastcall import __version__, catalog, engines +from lastcall.accounts import MODEL +from lastcall.engines.base import Keyed +from lastcall.github import Fetch, Response + + +def model(**fields: Any) -> dict[str, Any]: + """One model of a provider: it calls tools and has a price, until a field says otherwise.""" + return {"name": "A model", "tool_call": True, "cost": {"input": 1.5, "output": 7.5}, **fields} + + +CATALOG: dict[str, Any] = { + "openrouter": { + "id": "openrouter", + "name": "OpenRouter", + "models": { + "qwen/qwen3-coder": model(), + "Qwen/Qwen3-coder-flash": model(), # the order is alphabetical whatever the case + "qwen/qwen3-coder:free": model(cost={"input": 0, "output": 0}), # free is a price + "anthropic/claude-sonnet-5.5": model(), + "~anthropic/claude-sonnet-latest": model(), # an alias that an account cannot name: it starts with ~ + "legacy/old-model": model(status="deprecated"), + "beta/new-model": model(status="beta"), # not deprecated: it stays + "writer/no-tools": model(tool_call=False), + "writer/tools-unknown": {"cost": {"input": 1, "output": 2}}, + "writer/no-price": {"tool_call": True}, + "writer/half-price": model(cost={"input": 1}), + "writer/odd-price": model(cost={"input": "1", "output": True}), + }, + }, + "anthropic": {"id": "anthropic", "models": {"claude-sonnet-5-5": model(), "claude-opus-5-5": model()}}, + "openai": {"id": "openai", "models": {"gpt-6.1-sol": model(), "gpt-image-2": model(tool_call=False)}}, + "ollama-cloud": {"id": "ollama-cloud", "models": {"kimi-k3": model()}}, # not a provider that lastcall asks about +} +URL = "https://models.dev/api.json" +Answer = Mapping[str, Any] | Exception | tuple[int, bytes] + + +class ModelsDev: + """A `fetch` that answers what models.dev would: the catalog (gzipped, as it is when it is asked for gzip), or the + exception that a network that fails raises, or a status and a body. It records every request it gets.""" + + def __init__(self, answer: Answer = CATALOG, packed: bool = True) -> None: + self.answer, self.packed = answer, packed + self.asked: list[urllib.request.Request] = [] + + def __call__(self, req: urllib.request.Request) -> Response: + self.asked.append(req) + assert req.full_url == URL, req.full_url + if isinstance(self.answer, Exception): + raise self.answer + if isinstance(self.answer, tuple): + status, body = self.answer + return status, {}, body + body = json.dumps(self.answer).encode() + if self.packed: + return 200, {"Content-Type": "application/json", "Content-Encoding": "gzip"}, gzip.compress(body) + return 200, {"Content-Type": "application/json"}, body + + +def says(body: bytes, headers: Mapping[str, str] | None = None) -> Fetch: + """A `fetch` that answers 200 with this body and these headers, whatever it is asked.""" + + def fetch(req: urllib.request.Request) -> Response: + return 200, dict(headers or {}), body + + return fetch + + +def listed(provider: str) -> tuple[str, ...]: + return catalog.models(ModelsDev(), [provider], 5)[provider] + + +def test_it_keeps_the_models_that_a_step_can_run_on() -> None: + assert listed("openrouter") == ( + "anthropic/claude-sonnet-5.5", + "beta/new-model", + "qwen/qwen3-coder", + "Qwen/Qwen3-coder-flash", + "qwen/qwen3-coder:free", + ) + assert listed("anthropic") == ("claude-opus-5-5", "claude-sonnet-5-5") + assert listed("openai") == ("gpt-6.1-sol",) + + +def test_every_name_that_it_keeps_is_one_that_an_account_takes() -> None: + assert all(MODEL.fullmatch(name) for name in listed("openrouter")) + assert "~anthropic/claude-sonnet-latest" not in listed("openrouter") + + +def test_one_request_answers_for_every_provider_and_a_missing_provider_has_none() -> None: + fetch = ModelsDev() + found = catalog.models(fetch, ["openrouter", "anthropic", "openai", "nobody"], 5) + assert len(fetch.asked) == 1 + assert found["nobody"] == () and found["anthropic"] == ("claude-opus-5-5", "claude-sonnet-5-5") + assert "ollama-cloud" not in found # it keeps only the providers that it is asked about + + +def test_the_request_says_who_asks_wants_gzip_and_waits_5_seconds_and_carries_nothing_of_the_owner() -> None: + fetch = ModelsDev() + catalog.models(fetch, ["openai"], 5) + (req,) = fetch.asked + assert req.full_url == "https://models.dev/api.json" and req.get_method() == "GET" and req.data is None + assert req.get_header("User-agent") == f"lastcall/{__version__} (+https://github.com/ne0x86/lastcall)" + assert req.get_header("Accept-encoding") == "gzip" and getattr(req, "timeout", None) == 5 + assert not req.has_header("Authorization") and not req.has_header("Cookie") + + +@pytest.mark.parametrize("packed", [True, False]) +def test_a_gzipped_answer_and_a_plain_one_read_the_same(packed: bool) -> None: + found = catalog.models(ModelsDev(packed=packed), ["anthropic"], 5) + assert found == {"anthropic": ("claude-opus-5-5", "claude-sonnet-5-5")} + + +def test_the_header_that_says_gzip_is_read_whatever_its_case() -> None: + fetch = says(gzip.compress(json.dumps(CATALOG).encode()), {"content-encoding": " GZip "}) + assert catalog.models(fetch, ["openai"], 5) == {"openai": ("gpt-6.1-sol",)} + + +@pytest.mark.parametrize( + "answer", + [ + (404, b"{}"), + (403, gzip.compress(b"Forbidden")), # what models.dev says to a request with no User-Agent + (500, b""), + (200, b""), + (200, b"a captive portal"), + (200, b'["not", "the", "catalog"]'), + (200, b"null"), + OSError("no route to host"), + TimeoutError("timed out"), + ], +) +def test_an_answer_that_is_not_the_catalog_is_an_error(answer: Answer) -> None: + with pytest.raises((OSError, ValueError)): + catalog.models(ModelsDev(answer), ["openrouter"], 5) + + +def test_a_gzip_that_is_broken_is_an_error() -> None: + with pytest.raises(zlib.error): + catalog.models(says(b"this is not gzip", {"Content-Encoding": "gzip"}), ["openrouter"], 5) + + +@pytest.mark.parametrize( + "entry", + [None, [], "openrouter", 3, {}, {"models": None}, {"models": []}, {"models": "x"}, {"models": {"a/b": "text"}}], +) +def test_a_provider_that_does_not_look_like_one_has_no_models(entry: object) -> None: + assert catalog.models(ModelsDev({"p": entry}), ["p"], 5) == {"p": ()} + + +def test_what_else_the_catalog_says_about_a_model_does_not_matter() -> None: + odd = {"p": {"models": {"a/b": model(cost={"input": 1, "output": 2, "cache_read": None, "tiers": [{"x": 1}]})}}} + assert catalog.models(ModelsDev(odd), ["p"], 5) == {"p": ("a/b",)} + + +def test_a_provider_keeps_at_most_max_models_the_first_in_alphabetical_order(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(catalog, "MAX_MODELS", 3) + many = {"p": {"models": {f"vendor/m{n:02}": model() for n in range(20, 0, -1)}}} + assert catalog.models(ModelsDev(many), ["p"], 5) == {"p": ("vendor/m01", "vendor/m02", "vendor/m03")} + + +def test_an_answer_over_the_limit_is_refused_packed_or_not(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(catalog, "MAX_BYTES", 1000) + big = {"p": {"models": {f"vendor/model-{n}": model() for n in range(100)}}} + for packed in (True, False): + with pytest.raises(ValueError, match="too big"): + catalog.models(ModelsDev(big, packed=packed), ["p"], 5) + + +def test_a_few_kilobytes_that_unpack_to_megabytes_are_refused_while_they_unpack( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(catalog, "MAX_BYTES", 1024 * 1024) + bomb = gzip.compress(b" " * (20 * 1024 * 1024)) # 20 MB of spaces, 20 KB packed: under the limit as it comes + assert len(bomb) < 100 * 1024 + with pytest.raises(ValueError, match="too big"): + catalog.models(says(bomb, {"Content-Encoding": "gzip"}), ["openrouter"], 5) + + +def test_an_answer_of_exactly_the_limit_is_read(monkeypatch: pytest.MonkeyPatch) -> None: + body = json.dumps(CATALOG).encode() + monkeypatch.setattr(catalog, "MAX_BYTES", len(body)) # its size, plain or unpacked + assert catalog.models(says(body), ["openai"], 5) == {"openai": ("gpt-6.1-sol",)} + packed = says(gzip.compress(body), {"Content-Encoding": "gzip"}) + assert catalog.models(packed, ["openai"], 5) == {"openai": ("gpt-6.1-sol",)} + + +def test_each_api_provider_of_opencode_says_its_id_in_models_dev_and_a_local_server_has_none() -> None: + engine = engines.get("opencode") + assert isinstance(engine, Keyed) + found = {p.name: p.catalog for p in engine.providers} + assert found == {"openrouter": "openrouter", "anthropic": "anthropic", "openai": "openai", "ollama": ""} + assert all(p.keys for p in engine.providers if p.catalog) # an API takes a key; a local server has none diff --git a/tests/test_docs.py b/tests/test_docs.py index 4a59adf..e550d04 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -3,6 +3,7 @@ import json import re from pathlib import Path +from typing import Any ROOT = Path(__file__).resolve().parents[1] README = (ROOT / "README.md").read_text(encoding="utf-8") @@ -103,3 +104,66 @@ def test_the_documents_describe_the_state_chip_and_the_trash_button() -> None: assert '"/projects///active"' in source and '"/projects///remove"' in source cli = (ROOT / "src/lastcall/cli.py").read_text(encoding="utf-8") assert "lastcall project remove " in cli and "cli project remove " in README + + +def tiers_json() -> dict[str, Any]: + found: dict[str, Any] = json.loads((ROOT / "src/lastcall/data/tiers.json").read_text(encoding="utf-8")) + return found + + +def test_the_readme_example_of_a_default_is_the_one_in_data_tiers_json() -> None: + model, effort = tiers_json()["claude-code"]["deep"] + assert f"such as {model} at `{effort}` effort for Claude Code's *deep*" in " ".join(README.split()) + + +def test_the_documents_say_why_the_efforts_differ_and_name_the_ones_of_data_tiers_json() -> None: + data = tiers_json() + efforts = {tier: data["claude-code"][tier][1] for tier in ("fast", "balanced", "deep")} + assert efforts == {tier: data["codex"][tier][1] for tier in efforts} # one reason for both engines + assert len(set(efforts.values())) == 3 # they differ + readme = " ".join(README.split()) + assert "The efforts differ because the work does" in readme + assert f"*deep* runs at `{efforts['deep']}`" in readme and f"*balanced* at `{efforts['balanced']}`" in readme + assert f"at `{efforts['fast']}`." in readme + section = " ".join(DESIGN.split("### Tiers and models")[1].split("### Engines")[0].split()) + assert "The default efforts differ by tier because the work does" in section + assert f"so it takes `{efforts['fast']}`" in section and f"at `{efforts['balanced']}`" in section + assert f"at `{efforts['deep']}`" in section and "where quality matters most" in section + + +def test_the_documents_say_where_the_suggested_models_come_from() -> None: + import lastcall.web as site + from lastcall import catalog + + section = " ".join(DESIGN.split("### Tiers and models")[1].split("### Engines")[0].split()) + for words in ( + "**Suggested models.**", + "``", + "the `models` of its engine in the data file", + "https://models.dev/api.json", + "`Provider.catalog`", + "A local model server (Ollama) gets no suggestions", + "`fable` is left out on purpose", + ): + assert words in section, words + for name in tiers_json()["models"]["claude-code"]: + assert f"`{name}`" in section, name # the aliases that the data file lists + # the numbers that it gives are the code's + assert (site.CATALOG_TIMEOUT, site.CATALOG_SECONDS, site.CATALOG_RETRY_SECONDS) == (5, 86_400, 600) + assert "waits 5 seconds at most" in section and "stays in memory for a day" in section + assert "A failure is kept for 10 minutes" in section + assert catalog.MAX_MODELS == 1000 and "at most 1000 models" in section + assert "ask [models.dev](https://models.dev), about once a day, for the names of the models" in " ".join( + README.split() + ) + + +def test_the_documents_say_that_only_the_checks_and_the_health_address_take_part_in_the_watch() -> None: + after = " ".join(README.split("**After the merge.**")[1].split("\n## ")[0].split()) + assert ( + "Nothing else takes part in the watch: container logs, Loki and your error tracker feed the audits" in after + ) + deployment = " ".join(DESIGN.split("## 11. Delivery, approval and after the merge")[1].split("## 12.")[0].split()) + assert ( + "Errors in production feed the audits since v0.5, and container logs and their state since v0.6" in deployment + ) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 1064ea7..2f18c6f 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -95,3 +95,51 @@ def test_the_texts_about_the_checks_call_the_two_settings_as_the_projects_page_d permissions = text(lang, "web.help.github.permissions") # what the manifest asks for, and why for label in (merge, watch): assert label in lead and label in permissions, (lang, label) + + +def test_the_help_of_a_projects_language_quotes_the_option_where_it_really_is() -> None: + for lang in ("en", "es"): + assert text(lang, "web.project.language.account") in text(lang, "web.help.projects.language"), lang + + +def test_the_help_of_the_turns_calls_the_two_boxes_and_the_two_buttons_as_the_page_does() -> None: + for lang in ("en", "es"): + turns = text(lang, "web.help.projects.turns") + for key in ("web.project.tests", "web.project.deps", "web.project.tests_now", "web.project.deps_now"): + assert text(lang, key) in turns, (lang, key) + + +def test_the_help_of_the_watch_calls_the_active_box_as_the_page_does() -> None: + for lang in ("en", "es"): + assert text(lang, "web.project.active") in text(lang, "web.help.projects.watch"), lang + + +def test_the_hint_and_the_help_of_the_watch_both_say_that_logs_do_not_take_part() -> None: + said = { + "en": ("not your logs or error tracker", "take no part in the watch"), + "es": ("no tus logs ni tu tracker de errores", "no intervienen en la vigilancia"), + } + for lang, (hint, bubble) in said.items(): + assert hint in text(lang, "web.project.watch_hint"), lang + assert bubble in text(lang, "web.help.projects.watch"), lang + + +def test_the_help_about_effort_names_no_default_so_that_the_data_file_alone_decides_them() -> None: + for lang, catalog in MESSAGES.items(): + for key, template in catalog.items(): + if key.startswith(("web.tiers.", "web.help.accounts.")): + assert "xhigh" not in template, (lang, key) + + +def test_the_suggestions_say_where_they_come_from_in_both_languages() -> None: + for lang in ("en", "es"): + assert "models.dev" in text(lang, "web.tiers.suggest.catalog", provider="OpenRouter"), lang + assert "Codex" in text(lang, "web.tiers.suggest.cli", tool="Codex"), lang + assert "qwen/qwen3-coder" in text(lang, "web.accounts.model_suggest"), lang + + +def test_the_default_language_of_a_project_fits_a_closed_select_on_a_phone() -> None: + """A closed select cuts its text short: about 30 characters fit in a phone's, and the field is cut at 24 to leave + room for the narrower ones.""" + for lang in ("en", "es"): + assert len(text(lang, "web.project.language.account")) <= 24, lang diff --git a/tests/test_keyed.py b/tests/test_keyed.py index 783b661..2472381 100644 --- a/tests/test_keyed.py +++ b/tests/test_keyed.py @@ -65,7 +65,7 @@ def test_models_come_per_tier_and_keep_the_default_effort() -> None: assert tier_models("") == {} assert openrouter().tiers()["deep"] == ("qwen/qwen3-coder", None) claude = Account("claude", "claude-code", models={"deep": "sonnet"}) - assert claude.tiers()["deep"] == ("sonnet", "high") # the account's own model, the default effort + assert claude.tiers()["deep"] == ("sonnet", "xhigh") # the account's own model, the default effort assert claude.tiers()["fast"] == ("haiku", "medium") @@ -93,7 +93,7 @@ def test_an_account_chooses_the_model_and_effort_of_each_tier( row = store.account("max") assert row is not None and row.account().tiers() == claude.tiers() assert cli.main(["account", "tier", "max", "deep", "--effort", "-"]) == 0 # back to the default - assert "deep opus, effort high\n" in capsys.readouterr().out + assert "deep opus, effort xhigh\n" in capsys.readouterr().out assert cli.main(["account", "tier", "max", "balanced", "--model", "-"]) == 0 assert cli.main(["account", "tier", "max", "fast", "--effort", "low"]) == 0 assert "fast haiku, effort low (its own)" in capsys.readouterr().out @@ -287,7 +287,7 @@ def test_models_of_a_subscription(monkeypatch: pytest.MonkeyPatch, tmp_path: Pat == 0 ) row = Store(tmp_path / "state" / "lastcall.db").account("claude") - assert row is not None and row.account().tiers()["deep"] == ("sonnet", "high") + assert row is not None and row.account().tiers()["deep"] == ("sonnet", "xhigh") models: Mapping[str, str] = row.models assert set(models) == {"fast", "balanced", "deep"} diff --git a/tests/test_lists.py b/tests/test_lists.py new file mode 100644 index 0000000..fa99f94 --- /dev/null +++ b/tests/test_lists.py @@ -0,0 +1,773 @@ +"""Long lists in the web UI. Settings shows the containers of the host and the sources in Loki in three parts, so that +dozens of them stay short: what lastcall reads now, what it suggests, and the rest in a fold with a filter by name and +one field to give each a project. The Projects page gets a search by name, filters with their counts, and, with many +projects, a Run now that starts folded.""" + +import html +import re +from pathlib import Path +from typing import Any + +from lastcall import logs, loki +from lastcall.recipes.audit import AuditReport +from lastcall.recipes.improve import ImproveReport +from lastcall.web import project_views +from test_loki import FakeLoki +from test_projects import FakeGitHub +from test_web import NOW, OTHERS, SITE, Web, help_buttons_inside_a_paragraph, suggest_many + +ROOT = Path(__file__).resolve().parents[1] +PROJECTS = ("acme/shop", "acme/blog", "acme/api", "acme/Worker") + + +# the containers of a host + +NOT_MATCHED = "redis:7" + + +def box(name: str, image: str = NOT_MATCHED, compose: str = "", label: str = "") -> dict[str, str]: + return {"name": name, "image": image, "state": "running", "compose_project": compose, "label": label} + + +def host() -> list[dict[str, str]]: + """A host with dozens of containers: lastcall's own, one with a label, three that match a project, and 30 more.""" + return [ + box("lastcall-lastcall-1", "ghcr.io/ne0x86/lastcall-service:0.7.4", "lastcall"), + box("lastcall-supervisor-1", "ghcr.io/ne0x86/lastcall-supervisor:0.7.4", "lastcall"), + box("proxy-1", "tinyproxy", "lastcall"), # in lastcall's Compose project, whatever its name + box("blog", "nginx:1.27", label="ACME/blog"), + box("shop-web-1", "ghcr.io/acme/shop:1.2", "shop"), + box("shop-worker-1", "python:3.12", "shop"), + box("api-1", "ghcr.io/acme/api:3"), + *(box(f"cache-{n:02d}") for n in range(1, 31)), + ] + + +def settings_web(tmp_path: Path, listed: list[dict[str, str]] | None = None, **options: Any) -> Web: + """A signed-in web UI whose host lists `listed`, with the four projects.""" + found = host() if listed is None else listed + web = Web(tmp_path, containers=lambda: found, **options) + web.set_up() + for repo in PROJECTS: + web.store.add_project(repo) + return web + + +def section(page: str, name: str) -> str: + """The card of a section of the Settings page: "logs" or "loki".""" + return page.split(f'id="{name}">')[1].split("")[0] + + +def part(page: str, kind: str, which: str) -> str: + """One of the three parts of a list in the settings: "read", "suggested" or "others"; "" when it is not there.""" + body = section(page, kind).split("Or with a label in the compose file")[0] # what follows the lists is not theirs + marks = [ + ("read", f'id="{kind}-read-title"'), + ("suggested", f'id="{kind}-suggested-title"'), + ("others", '
    list[str]: + """The containers or sources that a part lists: those with a row or a tile of their own.""" + found = re.findall(r'([^<]+)|class="suggestion-name">([^<]+)', piece) + return [row or tile for row, tile in found] + + +def hidden(form: str) -> dict[str, str]: + """What a form sends without the owner touching it: its hidden fields.""" + found = re.findall(r'', form) + return {name: html.unescape(value) for name, value in found} + + +def row_of(form: dict[str, str], name: str) -> str: + """The number of the row that a form of the page gives to a container or a source.""" + return next(key for key, value in form.items() if value == name).removeprefix("name-") + + +def form_of(piece: str, css: str) -> str: + found = re.search(rf'(.*?)', piece, re.S) + assert found, css + return found.group(1) + + +def get(web: Web, path: str = "/settings", lang: str = "en") -> str: + return web.client.get(path, headers={"Accept-Language": lang}).get_data(as_text=True) + + +def post(web: Web, path: str, **fields: Any) -> Any: + return web.client.post(path, data={"csrf": web.csrf(), **fields}, headers=SITE) + + +def helps(page: str, topic: str) -> int: + """How many "?" buttons open the bubble of `topic` (its close button has a popovertarget too).""" + return len(re.findall(rf'popovertarget="help-projects-{topic}"(?! popovertargetaction)', page)) + + +def clean(page: str) -> None: + """What the content security policy would block, and a help button where it must not be.""" + assert " style=" not in page and "Read now" in read + assert 'acme/blog · its label decides' in read + assert 'acme/api · chosen here' in read + assert read.count('class="row-actions"') == 1 and 'aria-label="Stop reading cache-01"' in read + assert "Stop reading blog" not in read + # suggested: a checkbox with the container, and the project that fits + assert names(suggested) == ["shop-web-1", "shop-worker-1", "api-1"] + assert suggested.count("for acme/shop") == 2 and suggested.count("for acme/api") == 1 + assert re.findall(r'type="checkbox" name="tick" value="(\d)"', suggested) == ["1", "2", "3"] + # the buttons for many at once come first; only "Read the ticked" stays in the bar at the bottom of the screen + top, bar = suggested.split('
    ')[1], suggested.split('
    ')[1] + assert 'value="suggested"' in top.split("
    ")[0] and 'value="skip"' in top.split("
    ")[0] + assert re.findall(r'name="pick" value="(\w+)"', bar.split("")[0]) == ["selected"] + assert ">Read the 3 suggested" in suggested and ">Read the ticked" in suggested + # the rest, in a fold that says how many: 29 caches (cache-01 is read), with the one that was said no to + assert "29 other containers" in others and '
    ' in others + assert names(others) == [f"cache-{n:02d}" for n in range(2, 31)] + assert others.count('list="logs-projects"') == 29 and 'name="logs_q"' in others # many: a filter by name + # one list of projects for the whole section, not one in each row + assert card.count("') == 1 + clean(page) + # nothing is read until the owner chooses it: a suggestion is no choice + listed = host() + assert logs.names_for("acme/shop", listed, logs.chosen(web.store)) == [] + assert logs.names_for("acme/blog", listed, logs.chosen(web.store)) == ["blog"] + assert logs.names_for("acme/api", listed, logs.chosen(web.store)) == ["cache-01"] + spanish = get(web, lang="es") + assert ">Se lee ahora" in spanish and ">Sugeridos" in spanish + assert "29 otros contenedores" in spanish and "· lo decide su etiqueta" in spanish + assert ">Leer los 3 sugeridos" in spanish and ">Dejar de leer" in spanish + + +def test_the_parts_that_have_nothing_to_show_are_short(tmp_path: Path) -> None: + web = settings_web(tmp_path, [box("cache-01"), box("cache-02")]) + page = get(web) + assert "Nothing is read yet." in part(page, "logs", "read") + assert part(page, "logs", "suggested") == "" and "2 other containers" in page + assert 'name="logs_q"' not in page # two: no filter to look for them + nothing = settings_web(tmp_path / "none", [box("lastcall-lastcall-1", compose="lastcall")]) + assert "No containers on this machine besides lastcall's own." in get(nothing) + assert 'class="parts"' not in get(nothing) + only = settings_web(tmp_path / "one", [box("shop-web-1", "ghcr.io/acme/shop")]) + assert ">Read the suggested one" in get(only) + assert '
    None: + web = settings_web(tmp_path) + form = hidden(form_of(part(get(web), "logs", "suggested"), "pick")) + assert (form["name-1"], form["project-1"], form["name-3"], form["project-3"]) == ( + "shop-web-1", + "acme/shop", + "api-1", + "acme/api", + ) + response = post(web, "/settings/logs", **{**form, "tick": ["1", "3"], "pick": "selected"}) + assert response.headers["Location"] == "/settings?done=logs#logs" + assert logs.chosen(web.store) == {"shop-web-1": "acme/shop", "api-1": "acme/api"} + page = get(web) + assert names(part(page, "logs", "read")) == ["blog", "shop-web-1", "api-1"] # read now, and not suggested again + assert names(part(page, "logs", "suggested")) == ["shop-worker-1"] + # "all" does not look at what was ticked + form = hidden(form_of(part(page, "logs", "suggested"), "pick")) + assert post(web, "/settings/logs", **{**form, "tick": ["9"], "pick": "suggested"}).status_code == 302 + assert logs.chosen(web.store) == {"shop-web-1": "acme/shop", "api-1": "acme/api", "shop-worker-1": "acme/shop"} + assert 'id="logs-suggested-title"' not in get(web) # nothing left to suggest: no such part + assert "Saved. lastcall reads the logs of the containers that have a project." in get(web, "/settings?done=logs") + + +def test_ticking_nothing_says_so_and_changes_nothing(tmp_path: Path) -> None: + web = settings_web(tmp_path) + form = hidden(form_of(part(get(web), "logs", "suggested"), "pick")) + for tick in ([], ["7"]): # none, or a row that the form does not have + response = post(web, "/settings/logs", **{**form, "tick": tick, "pick": "selected"}) + page = response.get_data(as_text=True) + assert response.status_code == 400 and "Tick at least one container." in section(page, "logs") + assert 'role="alert"' in section(page, "logs") and logs.chosen(web.store) == {} + spanish = web.client.post( + "/settings/logs", + data={"csrf": web.csrf(), **form, "pick": "selected"}, + headers={**SITE, "Accept-Language": "es"}, + ) + assert "Marca al menos un contenedor." in spanish.get_data(as_text=True) + + +def test_skipping_a_suggestion_or_stopping_a_container_says_no_so_that_it_is_not_suggested_again( + tmp_path: Path, +) -> None: + web = settings_web(tmp_path) + logs.choose(web.store, {"cache-01": "acme/api"}) + page = get(web) + form = hidden(form_of(part(page, "logs", "suggested"), "pick")) + assert post(web, "/settings/logs", **{**form, "tick": ["2"], "pick": "skip"}).status_code == 302 + assert logs.chosen(web.store) == {"cache-01": "acme/api", "shop-worker-1": ""} + page = get(web) + assert names(part(page, "logs", "suggested")) == ["shop-web-1", "api-1"] + assert "shop-worker-1" in names(part(page, "logs", "others")) and "shop-worker-1" not in part( + page, "logs", "read" + ) + # Stop reading is a small form of its own: it posts the container and nothing else but the button + stop = hidden(form_of(part(page, "logs", "read"), "row-actions")) + assert stop == {"csrf": stop["csrf"], "pick": "stop", "name-1": "cache-01"} + assert post(web, "/settings/logs", **{k: v for k, v in stop.items() if k != "csrf"}).status_code == 302 + assert logs.chosen(web.store) == {"cache-01": "", "shop-worker-1": ""} + page = get(web) + assert names(part(page, "logs", "read")) == ["blog"] and "cache-01" in names(part(page, "logs", "others")) + assert logs.names_for("acme/api", host(), logs.chosen(web.store)) == [] + + +def test_a_project_typed_for_a_container_is_checked_against_the_projects_whatever_the_case(tmp_path: Path) -> None: + web = settings_web(tmp_path) + form = hidden(form_of(part(get(web), "logs", "others"), "others-form")) + assert form["name-1"] == "cache-01" and form["name-30"] == "cache-30" + # the page offers the projects once, to type against; the field says which container it is for + assert '' in get(web) and 'aria-label="Project for cache-01"' in get(web) + typed = {"project-1": " ACME/blog ", "project-2": "acme/WORKER", "project-3": ""} + response = post(web, "/settings/logs", **{**form, **typed, "pick": "others"}) + assert response.headers["Location"] == "/settings?done=logs#logs" + assert logs.chosen(web.store) == { + "cache-01": "acme/blog", + "cache-02": "acme/Worker", + } # as the Projects page writes them + # one that is not a project: nothing is saved, the page says which, and keeps what was typed, with the fold open + before = logs.chosen(web.store) + wrong = post( + web, "/settings/logs", **{**form, "project-4": "acme/shp", "project-5": "acme/API", "pick": "others"} + ) + page = wrong.get_data(as_text=True) + assert wrong.status_code == 400 and logs.chosen(web.store) == before + assert "“acme/shp” is not one of your projects." in section(page, "logs") + fold = part(page, "logs", "others") + assert fold.startswith('
    ') and 'value="acme/shp"' in fold + assert 'value="acme/API"' in fold # what was typed in the others is back: the owner fixes one + clean(page) + spanish = web.client.post( + "/settings/logs", + data={"csrf": web.csrf(), **form, "project-4": "acme/shp", "pick": "others"}, + headers={**SITE, "Accept-Language": "es"}, + ) + assert "«acme/shp» no es uno de tus proyectos." in spanish.get_data(as_text=True) + # what the owner typed is the owner's own: escaped, and given back no longer than a repository's name + odd = post(web, "/settings/logs", **{**form, "project-6": "x", "project-7": "x" * 300, "pick": "others"}) + page = odd.get_data(as_text=True) + assert odd.status_code == 400 and "<b>x</b>" in page and "x" not in page + assert f'value="{"x" * 140}"' in page and "x" * 141 not in page + clean(page) + + +def test_the_other_containers_are_filtered_by_name_from_the_address(tmp_path: Path) -> None: + web = settings_web(tmp_path) + page = get(web, "/settings?logs_q=CACHE-1&loki_q=zz") # whatever the case + fold = part(page, "logs", "others") + assert fold.startswith('
    ') # a filter that is set: no match is hidden + assert names(fold) == [f"cache-{n}" for n in range(10, 20)] + assert "10 of 30 match “CACHE-1”." in fold and 'Show all' in fold + assert '