Kyrux
Voltar

Admin (Painel de Administração)

Um único painel (/admin/) para todos os apps — mas nenhum model aparece nele por padrão. Diferente do admin do Django, o Kyrux não expõe nada automaticamente: sem admin.Register, /admin/ nem é montado. O layout segue o mesmo estilo visual da página de boas-vindas do framework (mesma paleta, header e rodapé) — os templates são embutidos, mas o código é seu: copie e adapte se quiser uma cara própria.

Ativar

Dois portões precisam estar abertos — o .env (kill-switch global) e o código (opt-in por model):

ADMIN_ENABLED=true
ADMIN_PATH=/admin/    # opcional — renomear dificulta descoberta por scanners
// Em models.go, logo abaixo do struct — não em routes.go. admin.Register
// não depende de r/fw (só faz reflection sobre o struct e guarda metadados
// num registry interno), então roda de dentro de um func init() comum,
// junto do próprio model:
package models

import "kyrux/core/admin"

type Produto struct {
    ID    int64   `kyrux:"pk"`
    Nome  string  `kyrux:"size:255"`
    Preco float64
}

func init() {
    admin.Register[Produto]("produtos", "Produtos",
        admin.SearchFields("Nome"),        // opcional — campos de busca (LIKE)
        admin.ListFields("Nome", "Preco"), // opcional — colunas da listagem
        admin.Conn("analytics"),           // opcional — outra conexão nomeada
    )
}

Go garante que todo init() do programa roda antes de main() começar — muito antes de admin.Mount, que só monta depois que todos os apps já registraram os seus (via bootstrap.RegisterApp). Não há risco de ordem.

Exceção: um model que já vive dentro de um pacote que core/admin importa (caso de auth.User, em core/security/auth — core/admin já importa esse pacote para login/sessão) não pode se auto-registrar assim: seria import cycle. Nesse caso, registre no Register(r, fw) do app, como antes.

Register[T] exige que o model tenha um campo kyrux:"pk" — falha no boot (panic) se não tiver, junto com slug inválido/duplicado/reservado (login, logout). Sem ListFields, a listagem mostra todos os campos exceto os marcados kyrux:"hash" (nunca exibidos, em lugar nenhum). Sem SearchFields, a busca fica desativada para aquele model.

Navegação lateral

A barra lateral é organizada em quatro tópicos fixos, sempre nesta ordem, cada um como uma caixa própria (fundo, borda e cantos arredondados) com ícone, separada das demais por espaçamento — não uma lista de texto corrida:

  1. Dashboard — caixa inteira é o link, leva para /admin/ (os cards de todos os models registrados).
  2. Framework — caixa com cabeçalho de seção (não clicável) seguido dos models declarados em algum pacote do próprio framework (kyrux/core/...), como auth.User. Útil quando você registra o model de usuário embutido para administrar contas pelo painel:
    admin.Register[auth.User]("usuarios", "Usuários", admin.SuperuserOnly())
    
  3. Aplicações — caixa com cabeçalho de seção seguido dos models declarados nos seus próprios apps (apps/<nome>/models).
  4. Logs — mesmo padrão de Dashboard: a caixa inteira é o link, leva direto para /admin/historico/ (ver Histórico de alterações abaixo).

A separação entre Framework e Aplicações é automática: o admin identifica a origem de T pelo pacote Go onde ele foi declarado (reflect.Type.PkgPath()), sem exigir nenhuma Option adicional em Register. Diferente das seções anteriores, os quatro tópicos aparecem sempre — um tópico sem nenhum model registrado mostra "Nenhum model" em vez de sumir, mantendo a navegação previsível.

Upload de imagem (kyrux:"image")

Um campo string marcado kyrux:"image" vira um input de upload no formulário do admin em vez de um campo de texto — o dev não precisa hospedar a imagem em outro lugar nem colar URL manualmente:

type Produto struct {
    ID    int64  `kyrux:"pk"`
    Nome  string `kyrux:"size:255"`
    Capa  string `kyrux:"size:500,image"` // vira <input type="file"> no admin
}

func init() {
    admin.Register[Produto]("produtos", "Produtos",
        admin.App("catalogo"), // obrigatório para todo model com campo image
    )
}
  • O arquivo é salvo em medias/<app>/<tabela>/<nome-único> (na raiz do projeto, ao lado de statics/) e servido de volta em /medias/<app>/<tabela>/<nome-único> — o valor gravado no campo já é esse caminho pronto para usar em <img src="...">, sem helper de template adicional.
  • admin.App("nome") é obrigatório em qualquer model com campo kyrux:"image" — é o que define a pasta de destino do upload; falha no boot (panic) se faltar.
  • Só aceita jpeg, png, gif e webp — validado pelos bytes reais do arquivo (http.DetectContentType), não pela extensão nem pelo Content-Type que o navegador declarou (ambos falsificáveis).
  • Nome do arquivo em disco é gerado (prefixo aleatório + nome original saneado) — nunca sobrescreve um upload anterior e nunca confia no nome enviado pelo navegador para montar o caminho (bloqueia path traversal do tipo ../../etc/passwd).
  • Na edição, deixar o campo em branco mantém a imagem atual (mesmo padrão de kyrux:"hash" para senha) — só troca se um novo arquivo for selecionado.
  • /medias/ nunca lista diretório (mesma proteção de /statics/) e recebe Cache-Control: immutable em produção — seguro porque cada upload tem nome único, nunca é sobrescrito no lugar.

Sem banco configurado

Se ADMIN_ENABLED=true e nenhum banco estiver configurado no .env, o comportamento depende do ambiente:

  • APP_ENV=development: o Kyrux cria sozinho um SQLite local em database/kyrux.sqlite3 (pasta já ignorada pelo git), registra-o como conexão "default" e gera automaticamente a tabela de auth.User e a de cada model registrado via admin.Register — o admin funciona de imediato, sem makemigrations/migrate. É a única situação em que o Kyrux abre um banco por conta própria; para qualquer banco real, o driver continua sendo escolha e responsabilidade sua.
  • APP_ENV=production: nada disso acontece. O Kyrux nunca cria um SQLite automaticamente em produção — escrever silenciosamente num arquivo local efêmero em vez do banco pretendido seria pior que simplesmente recusar. O log explica o motivo e o admin não é montado (404).

O fallback só faz CREATE TABLE IF NOT EXISTS — nunca ALTER. Se o model evoluir (novo campo), configure um banco de verdade e use makemigrations/migrate; o fallback é para começar rápido, não para acompanhar mudanças de schema ao longo do tempo.

Superusuário inicial

Duas formas de criar o primeiro usuário com acesso ao admin:

  1. Interativa — go run main.go createsuperuser (ver seção 4).
  2. Via .env — defina as duas variáveis abaixo; o Kyrux cria a conta sozinho no próximo boot, somente se ainda não existir ninguém com esse login:
ADMIN_SUPERUSER_USERNAME=admin
ADMIN_SUPERUSER_PASSWORD=troque-esta-senha-provisoria
  • O valor de ADMIN_SUPERUSER_USERNAME é gravado no campo marcado com kyrux:"login" no model auth.User — Username ou Email, conforme a tag (ver seção 15). O nome da variável não muda mesmo quando o campo de login é Email.
  • Nunca redefine a senha de uma conta já existente — mesmo que você edite ADMIN_SUPERUSER_PASSWORD depois e reinicie o servidor (Air recompila e reinicia a cada save; sem essa proteção, um .env esquecido resetaria a senha a cada hot reload). Para trocar a senha depois de criada, use o próprio /admin/ ou go run main.go createsuperuser/createuser.
  • Senha com menos de 8 caracteres é rejeitada (a criação falha e o log explica o motivo — nenhuma conta é criada).
  • Funciona com qualquer banco, inclusive o SQLite de fallback acima — nesse caso a tabela de auth.User já existe antes desta checagem rodar.
  • Deixe as duas variáveis vazias (padrão) para desativar — nada acontece no boot, use createsuperuser normalmente.

Segurança

  • Acesso exige IsStaff ou IsAdmin no model auth.User, não apenas no login. O resultado positivo fica cacheado na sessão por até 5s (evita um SELECT a cada requisição — medido ~27x mais rápido com cache quente); revogar IsStaff de um usuário encerra o acesso em até 5s, não mais instantaneamente. Pra revogação imediata de verdade, encerre a sessão do usuário diretamente (ex: store.Delete(sessionID)) em vez de só editar o campo no banco.
  • Login válido não implica acesso. Um usuário sem IsStaff/IsAdmin que autentica corretamente no /admin/login/ recebe "sua conta não tem permissão" e a sessão recém-criada é imediatamente revogada.
  • Brute-force: o freio embutido em auth.Login (10 falhas/minuto por conta+IP) já protege o login do admin — nenhuma configuração adicional.
  • CSRF roda pelo middleware global de sempre — todo formulário do admin inclui o token automaticamente.
  • Campos kyrux:"hash" nunca são exibidos — nem mascarados a partir do valor real, o valor simplesmente não sai do banco para o template. Na edição, o campo de senha fica em branco: preencher define uma nova senha (hash automático), deixar em branco mantém a atual.
  • Sem banco disponível, sem admin: se mesmo após a tentativa de fallback (ou em produção, onde não há fallback) a conexão "default" não existir, o admin recusa montar — não há como protegê-lo sem autenticação, então ele simplesmente não sobe (log explica o motivo).
  • Toda entrada do usuário (busca, ordenação, paginação) é validada contra os metadados reais do model antes de chegar ao SQL — sort=coluna_falsa é silenciosamente ignorado, nunca vira uma coluna arbitrária na query.

O que o admin faz e o que não faz

Recurso Suporte
CRUD completo (criar, listar, editar, excluir) ✅
Busca textual (LIKE) ✅ — opt-in via SearchFields
Ordenação por coluna (clique no cabeçalho) ✅
Paginação ✅ — Anterior/Próxima, sem total exato (ver nota abaixo)
hash/encrypt automático na escrita ✅ — reaproveita orm.Create/Query.Update
Upload de imagem ✅ — kyrux:"image" + admin.App("nome")
Múltiplas conexões ✅ — admin.Conn("nome")
FK como <select> (só ids existentes) ✅ — automático em qualquer kyrux:"fk:tabela"; rótulo via kyrux:"fklabel:coluna", combinável com + e colunas de outra tabela; aparece também na listagem
Filtros avançados na listagem ✅ — opt-in via FilterFields
Ações em lote ✅ — excluir selecionados embutido; extensível via BulkAction
Histórico de alterações ✅ — automático em todo model registrado, sem Option
Inlines (editar relação dentro do model pai) ❌

Inlines ficam de fora por design nesta primeira versão — o objetivo é um CRUD sólido e seguro sobre um model por vez, não replicar toda a superfície do Django admin.

FK como <select> (kyrux:"fklabel:coluna")

Todo campo com kyrux:"fk:tabela" vira automaticamente um <select> no admin, populado com as linhas que realmente existem em tabela — evita salvar um id que não existe (o que antes só falhava depois, na constraint do banco). Sem fklabel, cada opção mostra o próprio id; com fklabel, mostra a coluna indicada — e a listagem do model (não só o formulário) também passa a mostrar esse rótulo em vez do id bruto na coluna da FK:

type Pedido struct {
    ID         int64  `kyrux:"pk"`
    ClienteID  int64  `kyrux:"column:cliente_id,fk:clientes,fklabel:nome"`
}

O rótulo sempre sai em maiúsculas (ex: "JOÃO SILVA").

Rótulo combinando colunas de outra tabela (tabela.coluna+coluna)

fklabel aceita partes separadas por +. Cada parte é coluna (do próprio model referenciado) ou tabela.coluna — segue o FK do model referenciado até essa tabela, resolvido automaticamente a partir dos models já registrados no admin (precisa existir, na tabela referenciada, um campo com kyrux:"fk:tabela" apontando pra ela). Múltiplas partes saem concatenadas com " - ":

type Projeto struct {
    ID        int64  `kyrux:"pk"`
    ClienteID int64  `kyrux:"column:cliente_id,fk:clientes,fklabel:nome"`
    Titulo    string `kyrux:"size:255"`
}

type Galeria struct {
    ID        int64  `kyrux:"pk"`
    ProjetoID int64  `kyrux:"column:projeto_id,fk:projetos,fklabel:clientes.nome+titulo"`
}

O <select> de ProjetoID (e a coluna da listagem de Galeria) mostram "NOME DO CLIENTE - TÍTULO DO PROJETO" em vez do id: segue projetos.cliente_id até clientes, pega nome de lá, concatena com projetos.titulo.

  • A tabela referenciada precisa ter uma coluna id (mesma suposição já feita pela migration ao gerar REFERENCES tabela(id)).
  • O <select> e a resolução da listagem carregam até 1000 linhas (ORDER BY pelo próprio rótulo) — tabelas maiores continuam funcionais, só não listam/resolvem tudo (o id bruto aparece como fallback além do limite).
  • Editando um registro cujo valor atual não está mais entre as opções carregadas (linha órfã/excluída), o valor continua visível como uma opção extra em vez de sumir silenciosamente do formulário.
  • Sintaxe inválida em fklabel (ex: caracteres fora de identificador SQL) causa panic no boot, mesmo nível de validação de qualquer tag kyrux mal configurada. Já uma parte tabela.coluna sintaticamente válida mas sem kyrux:"fk:tabela" correspondente na tabela referenciada só falha em runtime, ao abrir o formulário/listagem daquele model.

A listagem usa PaginateNoCount internamente (não Paginate) — a página mostra "Página N" e Anterior/Próxima, mas não "N de M" nem o total de registros. Medido: evitar o SELECT COUNT(*) a cada carregamento da lista é ~40% mais rápido no nível do ORM (benchmark isolado, sem HTTP/sessão no meio). Se seu caso de uso precisa do total exato, troque para Paginate em core/admin/crud.go — é a única linha que muda.

Filtros avançados (FilterFields)

FilterFields adiciona um filtro por campo na listagem, além da busca textual — o tipo de filtro é derivado automaticamente do widget do campo, sem nenhuma configuração extra:

type Produto struct {
    ID          int64     `kyrux:"pk"`
    Nome        string    `kyrux:"size:200"`
    Ativo       bool
    CategoriaID int64     `kyrux:"column:categoria_id,fk:categorias,fklabel:nome"`
    Preco       float64
    CriadoEm    time.Time `kyrux:"column:criado_em"`
}

admin.Register[models.Produto]("produtos", "Produtos",
    admin.FilterFields("Ativo", "CategoriaID", "Preco", "CriadoEm"),
)
Widget do campo Filtro gerado
checkbox (bool) Exato — <select> com Todos/Sim/Não
select (kyrux:"fk:...") Exato — mesmas opções do <select> do formulário
number / number-float Faixa — dois campos, mínimo e máximo
datetime Faixa de datas — dois campos, de/até (o limite superior é exclusivo no dia seguinte, cobrindo o dia inteiro independente da hora armazenada)

Campos com outros widgets (text, textarea, password, file) não têm filtro coerente e fazem FilterFields panicar no boot, com o nome do campo e o motivo — mesmo espírito de ListFields/SearchFields com nome desconhecido. Os filtros ativos são preservados nos links de ordenação e paginação da própria listagem; "Limpar" reseta busca e filtros de uma vez.

Ações em lote (BulkAction)

A listagem sempre tem uma coluna de checkbox por linha (mais "selecionar todos" no cabeçalho) e a ação embutida Excluir selecionados — um único DELETE ... WHERE id IN (...), não N exclusões individuais. BulkAction registra ações adicionais, aplicadas aos ids marcados:

admin.Register[models.Produto]("produtos", "Produtos",
    admin.BulkAction("ativar", "Ativar selecionados", func(db *database.DB, pks []any) error {
        return orm.FromDB[models.Produto](db).WhereIn("id", pks...).Update(map[string]any{"ativo": true})
    }),
)
  • pks já vem convertido para o tipo Go real da coluna de PK (mesma conversão usada por uma exclusão/edição individual) — pronto para WhereIn("id", pks...).
  • O nome "delete" é reservado pela ação embutida — BulkAction("delete", ...) panica no boot. Duas BulkAction com o mesmo nome também panicam.
  • Sem nenhum id selecionado, a ação não roda e a listagem volta com o flash "Nenhum registro selecionado." Uma ação com nome desconhecido (POST manual/adulterado) devolve 400.
  • No navegador, o botão "Aplicar" pede confirmação antes de submeter — igual à exclusão individual, para evitar clique acidental numa ação irreversível.

Histórico de alterações

Toda ação feita através do admin — criar, editar, excluir (individual ou em lote, embutida ou via BulkAction) — gera automaticamente uma entrada de auditoria: quem fez, quando, em qual model/registro. Não é opt-in nem precisa de nenhuma Option — vale para todo model registrado via admin.Register, desde a primeira versão que inclui este recurso.

Acesse em Histórico, fixo no topo da barra lateral (/admin/historico/), com filtro por model. Cada entrada mostra:

Coluna Conteúdo
Data Quando a ação aconteceu
Model O rótulo do model (admin.Register("slug", "Rótulo", ...))
Registro O valor da PK afetada
Ação create, update, delete, ou o name de uma BulkAction
Alterações Para create/update: os campos submetidos (campo: valor). Vazio para delete e ações em lote — nenhuma dessas grava um diff de valores nesta versão
Usuário Quem estava autenticado quando a ação rodou
  • Campos sensíveis nunca aparecem em Alterações: kyrux:"hash" e kyrux:"encrypt" são sempre gravados como ***, mesma proteção que já vale pro resto do admin — o histórico é uma cópia secundária dos dados que fica indefinidamente, não pode ser um jeito indireto de vazar senha ou o plaintext de um campo cifrado.
  • Modelos SuperuserOnly() também ficam escondidos no histórico para quem só tem IsStaff — a mesma restrição que já vale pro model em si.
  • Cada entrada mora na mesma conexão do registro que ela descreve (a conexão do model — admin.Conn("nome") — não sempre "default"). A página Histórico sem filtro mostra a conexão "default"; para ver o histórico de um model configurado noutra conexão nomeada, filtre por ele explicitamente (o filtro resolve a conexão certa automaticamente).
  • A tabela (history_logs) é criada automaticamente junto com a de auth.User — no SQLite de fallback (development, sem banco configurado) e via makemigrations, que agora também escaneia core/admin (mesmo tratamento já dado a core/security/auth).

O render de cada página do admin (core/admin/templates.go) reaproveita o buffer de saída via sync.Pool entre requisições, em vez de alocar um bytes.Buffer novo a cada render — mesmo padrão já usado pelo motor de templates da aplicação (core/render). Medido (microbenchmark isolado): ~26% menos memória alocada por render e ~7% menos tempo, sem nenhuma mudança de comportamento observável.

admin.Mount, admin.EnsureAllTables e admin.Count são exportadas mas de uso interno — bootstrap.Init() já as chama sozinho na ordem certa (depois que os apps registraram seus models, antes de montar as rotas). Código de app não precisa (nem deve) chamá-las diretamente; a única API voltada para o desenvolvedor é admin.Register[T] e suas opções.